Protocolo OData v4: Metadatos y consultas básicas

El tratamiento y el consumo de datos son elementos esenciales del mundo empresarial contemporáneo. Por eso, existen unas misteriosas piezas de software, comúnmente abreviadas como APIs, cuyo rol es fundamental en éste tráfico de información. 

Las API (Application Programming Interface) son mecanismos de integración y comunicación entre máquinas, servicios o programas. Mediante ellas, los sistemas exponen funcionalidades y/o datos que pueden ser consumidos por otros sistemas o servicios. Su esencia es ser interfaces entre sistemas para facilitar la compatibilidad e interoperabilidad, independientemente de su funcionamiento o de las tecnologías que implementan.

En la actualidad, hay una gran variedad de protocolos de APIs en la industria con el objetivo de estandarizar de alguna forma las comunicaciones entre sistemas. En este artículo, empezaremos a explorar el protocolo OData v4 (Open Data Protocol), un estándar OASIS que se construye sobre los principios de las APIs RESTful y que permite exponer fuentes de datos y realizar consultas de manera muy sencilla.

APIS REST y APIS OData

Antes que nada y por completitud, vamos a introducir brevemente qué son las API REST (Representational State Transfer). Estas APIs definen la comunicación generalmente a través del protocolo HTTP utilizando un conjunto de operaciones estándar como GET, POST, PUT y DELETE para interactuar con los recursos. En su implementación tienden a ser sencillas y escalables y utilizan URLs legibles para acceder a los datos, aprovechando los métodos y estatus de HTTP (como el famoso “404 Not Found”) para realizar y gestionar las solicitudes y respuestas. Las APIs que siguen de forma rigurosa el protocolo REST se denominan comúnmente APIs RESTful.

Por otra parte, el protocolo OData fue desarrollado originalmente por Microsoft en 2007 y se ha convertido en un estándar abierto ampliamente adoptado. Su objetivo es facilitar aún más la interoperabilidad entre diferentes aplicaciones proporcionando un estándar de interacción con fuentes de datos. Esto lo consigue, principalmente, partiendo de un estándar de metadatos asociados a cada fuente en formato CSDL.XML (Conceptual Schema Definition Language).

Gracias a ellos, el cliente puede descubrir la estructura de los datos expuestos sin depender de ninguna documentación o contacto con quien mantiene el servicio. Sobre esto, OData define una amplia gama de operaciones, incluyendo filtrado, ordenamiento y paginación, que acaban transformadas, por ejemplo, en sentencias SQL para ser entregadas a la fuente de datos. Además, también se definen cómo deben ser las respuestas entregadas por los servicios. 

Finalmente, aunque OData está frecuentemente asociado con bases de datos relacionales, también puede utilizarse con bases de datos no relacionales, abarcando así una gran número de fuentes de datos en nuestros sistemas.

Metadatos en OData v4

En este primer artículo sobre el protocolo OData, nos vamos a centrar exclusivamente en explicar qué hay en los metadatos de un servicio OData v4 y veremos también cómo acceder a los datos mediante algunas instrucciones básicas. El fichero de metadatos CSDL lo vamos a dividir en tres partes tal y como se muestra en la siguiente imagen.

Además, para entender mejor sus detalles, vamos a despedazar los metadatos con un caso práctico, ya que el protocolo OData es en verdad muy extenso y no vamos a cubrirlo todo. Para ello, nos valdremos del servicio de muestra de la propia web del estándar OData v4. Allí mismo se puede descargar también una colección de Postman con algunos ejemplos de cómo interactuar con la API.

Tipos, propiedades y entidades

Imaginemos por un momento un servicio externo que intenta sincronizarse con esta API OData de prueba, de bien seguro lo primero que hará será pedir los metadatos para saber qué esperar y cómo interactuar con ella. Para hacerlo, usará el parámetro ‘metadata’ sobre la raíz del servicio y éste responderá con el fichero CSDL.XML. Este fichero, como hemos dicho, es el manual de operaciones del servicio, en él se nos informa de cómo están estructurados los datos y cómo los podemos pedir. Veamos ahora un primer fragmento del mismo tras lanzar la siguiente instrucción:

GET basePath/TripPinRESTierService/$metadata
<EntityType Name="Person">
    <Key>
        <PropertyRef Name="UserName" />
    </Key>
    <Property Name="UserName" Type="Edm.String" Nullable="false" />
    <Property Name="FirstName" Type="Edm.String" Nullable="false" />
    <Property Name="LastName" Type="Edm.String" MaxLength="26" />
    <Property Name="MiddleName" Type="Edm.String" />
    <Property Name="Gender" Type="Trippin.PersonGender" Nullable="false" />
    <Property Name="Age" Type="Edm.Int64" />
    <Property Name="Emails" Type="Collection(Edm.String)" />
    <Property Name="AddressInfo" Type="Collection(Trippin.Location)" />
    <Property Name="HomeAddress" Type="Trippin.Location" />
    <Property Name="FavoriteFeature" Type="Trippin.Feature" Nullable="false" />
    <Property Name="Features" Type="Collection(Trippin.Feature)" Nullable="false" />
    <NavigationProperty Name="Friends" Type="Collection(Trippin.Person)" />
    <NavigationProperty Name="BestFriend" Type="Trippin.Person" />
    <NavigationProperty Name="Trips" Type="Collection(Trippin.Trip)" />
</EntityType>

<EntityType Name="Airline">
    <Key>
        <PropertyRef Name="AirlineCode" />
    </Key>
    <Property Name="AirlineCode" Type="Edm.String" Nullable="false" />
    <Property Name="Name" Type="Edm.String" />
</EntityType>

<EntityType Name="Airport">
    <Key>
        <PropertyRef Name="IcaoCode" />
    </Key>
    <Property Name="Name" Type="Edm.String" />
    <Property Name="IcaoCode" Type="Edm.String" Nullable="false" />
    <Property Name="IataCode" Type="Edm.String" />
    <Property Name="Location" Type="Trippin.AirportLocation" />
</EntityType>

Como podemos ver en este recorte del fichero CSDL.XML, debajo de las cuatro primeras líneas de cabecera donde se define la versión de OData y el nombre del Namespace (las hemos excluido), aparecen tres EntityTypes distintos: ‘Person’, ‘Airline’ y ‘Airport’. Como bien indica su nombre, en cada uno de ellos se define la estructura de una tipo de entidad presente en la fuente de datos. 

Cada EntityType tiene definida una Key, unas Porperty y, en el caso de la entidad ‘Person’, también unas NavigationProperty. Las llaves siempre hacen referencia al nombre de alguna de las propiedades de la entidad y pueden contener varias de ellas. Esto se debe a que las propiedades son en realidad las columnas o campos de la entidad en cuestión que incluyen, además del nombre, los tipos de datos y otra información opcional como su longitud máxima (en caso de tratarse de texto) o si puede contener valores nulos. 

Por otro lado, con las NavigationProperty definimos enlaces a otras entidades, como en el caso de ‘BestFriend’ siendo un enlace a otra instancia del tipo ‘Person’, o ‘Friends’ siendo un enlace a una colección de entidades tipo ‘Person’. Para obtener los datos de la tabla ‘Person’, vamos a añadir sobre la raíz la palabra ‘People’ (el porqué de este cambio de nombre lo explicaremos más adelante):

GET basePath/TripPinRESTierService/People
{
    "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(3mslpb2bc0k5ufk24olpghzx))/$metadata#People",
    "value": [
        {
            "UserName": "russellwhyte",
            "FirstName": "Russell",
            "LastName": "Whyte",
            "MiddleName": null,
            "Gender": "Male",
            "Age": null,
            "Emails": [
                "Russell@example.com",
                "Russell@contoso.com"
            ],
            "FavoriteFeature": "Feature1",
            "Features": [
                "Feature1",
                "Feature2"
            ],
            "AddressInfo": [
                {
                    "Address": "187 Suffolk Ln.",
                    "City": {
                        "Name": "Boise",
                        "CountryRegion": "United States",
                        "Region": "ID"
                    }
                }
            ],
            "HomeAddress": null
        },
        {"Person2"},
        {"Person3"},
        "...",
        {"PersonN"}
    ]
}

La API ha respondido con un mensaje con dos elementos principales: el contexto OData y el valor de la respuesta. El contexto no es más que la localización del esquema de los datos en los metadatos. El valor de la respuesta son los datos en sí, que en este caso contienen todas las entradas tipo ‘Person’ en la fuente de datos. Usando la llave de la entidad entre paréntesis podemos pedir entidades individuales de ‘Person’, por ejemplo usando People('russellwhyte') para obtener tan solo la ‘Person’ con UserName == russellwhyte.

Así, como vemos, los valores por todas las Property de la entidad, que son sus campos, no aparece nada respecto de las NavigationProperty. Esto se debe a que, por defecto, no se muestran, ya que hacerlo podría traer problemas. Para mostrarlas, tenemos que usar uno de los parámetros de consultas, el ‘expand’, pudiendo pedir por más de una de ellas separándolas por comas. Por ejemplo:

GET basePath/TripPinRESTierService/People('russellwhyte')?$expand=BestFriend,Friends
{
    "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(e2canygeoavavzu0nrasd4ov))/$metadata#People(BestFriend(),Friends())/$entity",
    "UserName": "scottketchum",
    "FirstName": "Scott",
    "LastName": "Ketchum",
    "MiddleName": null,
    "Gender": "Male",
    "Age": null,
    "Emails": [
        "Scott@example.com"
    ],
    "FavoriteFeature": "Feature1",
    "Features": [],
    "AddressInfo": [
        {
            "Address": "2817 Milton Dr.",
            "City": {
                "Name": "Albuquerque",
                "CountryRegion": "United States",
                "Region": "NM"
            }
        }
    ],
    "HomeAddress": null,
    "BestFriend": {
        "UserName": "russellwhyte",
        "FirstName": "Russell",
        "LastName": "Whyte",
        "MiddleName": null,
        "Gender": "Male",
        "Age": null,
        "Emails": [
            "Russell@example.com",
            "Russell@contoso.com"
        ],
        "FavoriteFeature": "Feature1",
        "Features": [
            "Feature1",
            "Feature2"
        ],
        "AddressInfo": [
            {
                "Address": "187 Suffolk Ln.",
                "City": {
                    "Name": "Boise",
                    "CountryRegion": "United States",
                    "Region": "ID"
                }
            }
        ],
        "HomeAddress": null
    },
    "Friends": [
        {
            "UserName": "russellwhyte",
            "FirstName": "Russell",
            "LastName": "Whyte",
            "MiddleName": null,
            "Gender": "Male",
            "Age": null,
            "Emails": [
                "Russell@example.com",
                "Russell@contoso.com"
            ],
            "FavoriteFeature": "Feature1",
            "Features": [
                "Feature1",
                "Feature2"
            ],
            "AddressInfo": [
                {
                    "Address": "187 Suffolk Ln.",
                    "City": {
                        "Name": "Boise",
                        "CountryRegion": "United States",
                        "Region": "ID"
                    }
                }
            ],
            "HomeAddress": null
        },
        {
            "UserName": "ronaldmundy",
            "FirstName": "Ronald",
            "LastName": "Mundy",
            "MiddleName": null,
            "Gender": "Male",
            "Age": null,
            "Emails": [
                "Ronald@example.com",
                "Ronald@contoso.com"
            ],
            "FavoriteFeature": "Feature1",
            "Features": [],
            "AddressInfo": [
                {
                    "Address": "187 Suffolk Ln.",
                    "City": {
                        "Name": "Boise",
                        "CountryRegion": "United States",
                        "Region": "ID"
                    }
                }
            ],
            "HomeAddress": null
        }
    ]
}

Aquí nos fijaremos en dos cosas. En primer lugar, vemos que el mensaje ya no tiene el campo “value”, sino que directamente añade el campo “@odata.context” a las propiedades de la entidad. Esto se debe a que ahora ya no pedimos una colección de entidades, sino un solo elemento con los paréntesis. En segundo lugar, vemos que esta vez sí que aparecen los campos “BestFriend” y “Friends” en la respuesta, siendo éstos entidades o colecciones del tipo ‘Person’ pero que a su vez están sin sus NavigationProperties. 

Este ejemplo nos da una pista muy clara del peligro que supondría mostrar siempre las NavigationProperties. Yo soy amigo de mi amigo que es mi amigo y yo su amigo … Entraríamos en un bucle infinito de referencias mutuas.

Para cerrar este bloque, pasaremos a hablar sobre los tipos de datos disponibles para las Property y las NavigationProperty. El estándar define los mayormente comunes en el mundillo de la programación como los String, y se nombran usando las siglas EDM (Entity Data Model) antes del nombre (Edm.String). No por esto deja de ser muy importante verificar siempre la compatibilidad de tipos entre nuestras fuentes de datos y los tipos de OData. Además de los estándar, OData permite definir tipos propios, incluidos los EntityTypes, como ya hemos visto con las NavegationProperty, los EnumTypes y los ComplexTypes. En la siguiente parte del fichero vemos algunos ejemplos.

<EnumType Name="PersonGender">
    <Member Name="Male" Value="0" />
    <Member Name="Female" Value="1" />
    <Member Name="Unknown" Value="2" />
</EnumType>

 <ComplexType Name="Location">
    <Property Name="Address" Type="Edm.String" />
    <Property Name="City" Type="Trippin.City" />
 </ComplexType>

<ComplexType Name="City">
    <Property Name="Name" Type="Edm.String" />
    <Property Name="CountryRegion" Type="Edm.String" />
    <Property Name="Region" Type="Edm.String" />
</ComplexType>

<EntityType Name="Employee" BaseType="Trippin.Person">
    <Property Name="Cost" Type="Edm.Int64" Nullable="false" />
    <NavigationProperty Name="Peers" Type="Collection(Trippin.Person)" />
</EntityType>

Los EnumTypes, como puede verse, sirven para definir enumeraciones. Son muy útiles para campos con valores únicos y limitados. Por otra parte, los ComplexTypes se parecen mucho a los EntityTypes con la diferencia de no tener ninguna Key y que no pueden ser referenciados por sí solos, siempre tienen que ser un tipo de un campo de un EntityType. En el ejemplo anterior, vemos que incluso un ComplexType como ‘City’ puede usarse dentro de otro ComplexType como ‘Location’. También podemos ver cómo se pueden definir nuevos EntityType usando otros como base, por ejemplo, en el caso de ‘Employee’.

Acciones y Funciones

El estándar OData se basa en las operaciones CRUD (Create, Read, Update, Delete) para interactuar con las fuentes de datos, pero a su vez permite extender las capacidades de éstas mediante el uso de Acciones y Funciones, definidas e integradas en la fuente de datos. 

La principal diferencia entre acciones y funciones es que las acciones pueden modificar el estado de la fuente de datos, como por ejemplo actualizar un registro, mientras que las funciones no. Además, dados unos parámetros y un estado de la base de datos, las funciones siempre van a devolver los mismos resultados, mientras que en las acciones esto no tiene por qué así.

Finalmente, para invocar funciones se usa el método HTTP GET, mientras que para las acciones se usa el POST. Esto se debe a que, en general, las funciones sirven para leer datos, mientras que las acciones los pueden insertar, actualizar o borrar. Veamos algunos ejemplos de las dos en los metadatos:

<Function Name="GetPersonWithMostFriends">
    <ReturnType Type="Trippin.Person" />
</Function>

<Function Name="GetFriendsTrips" IsBound="true">
    <Parameter Name="person" Type="Trippin.Person" />
    <Parameter Name="userName" Type="Edm.String" Nullable="false" Unicode="false" />
    <ReturnType Type="Collection(Trippin.Trip)" />
</Function>

<Function Name="GetInvolvedPeople" IsBound="true">
    <Parameter Name="trip" Type="Trippin.Trip" />
    <ReturnType Type="Collection(Trippin.Person)" />
</Function>

<Action Name="ResetDataSource" />

<Action Name="UpdateLastName" IsBound="true">
    <Parameter Name="person" Type="Trippin.Person" />
    <Parameter Name="lastName" Type="Edm.String" Nullable="false" Unicode="false" />
    <ReturnType Type="Edm.Boolean" Nullable="false" />
</Action>

<Action Name="ShareTrip" IsBound="true">
    <Parameter Name="personInstance" Type="Trippin.Person" />
    <Parameter Name="userName" Type="Edm.String" Nullable="false" Unicode="false" />
    <Parameter Name="tripId" Type="Edm.Int32" Nullable="false" />
</Action>

En este extracto del fichero de metadatos vemos tres funciones y tres acciones. En los dos tipos de métodos tenemos definidos el nombre y tipo de los parámetros, así como los tipos para los datos que retornan. Todo esto si efectivamente estos métodos necesitan parámetros o responden algo en forma de mensaje. 

Por ejemplo, la acción “ResetDataSource” presumiblemente devuelve la base de datos a un estado base, sin devolver ningún mensaje ni requerir parámetro. Tanto las acciones como las funciones pueden tener la etiqueta booleana “IsBound” definida, que por defecto será falsa. Esta etiqueta nos indica si la función o acción están vinculadas a un tipo de entidad en concreto, que el estándar siempre asocia a el tipo del primer parámetro de la función. 

Además, para evitar ambigüedades a la hora de invocar una función o acción, existe también la etiqueta “EntitySetPath”, que apunta a la entidad sobre la que podemos invocar los métodos. Para llamar algunas de estas funciones y acciones usaríamos URLs de este tipo:

GET basePath/TripPinRESTierService/GetPersonWithMostFriends

GET basePath/TripPinRESTierService/People('russellwhyte')/GetFriendsTrips(userName='scottketchum') 

POST basePath/Trippin.svc/People(UserName='russellwhyte')/UpdateLastName
Content-Type: application/json
{
    "lastName": "Smith"
}

Contenedor de entidades

Como punto y final en nuestro viaje por los metadatos de OData, vamos a hablar del contenedor de entidades. Aquí es donde realmente pasa la magia y todas las piezas del rompecabezas encajan.

Los contenedores tienen un nombre, en este caso “Container”, y dentro de ellos se organizan y definen los recursos realmente accesibles desde el exterior con la API. Por lo que todo lo que no esté aquí dentro, por muy definido que esté en el fichero CSDL, no existe desde el punto de vista del consumidor del servicio. Veamos el EntityContainer de esta API:

<EntityContainer Name="Container">

    <EntitySet Name="People" EntityType="Trippin.Person">
        <NavigationPropertyBinding Path="BestFriend" Target="People" />
        <NavigationPropertyBinding Path="Friends" Target="People" />
    </EntitySet>

    <EntitySet Name="Airlines" EntityType="Trippin.Airline">
        <Annotation Term="Org.OData.Core.V1.OptimisticConcurrency">
            <Collection>
                <PropertyPath>Name</PropertyPath>
            </Collection>
        </Annotation>
    </EntitySet>

    <EntitySet Name="Airports" EntityType="Trippin.Airport" />

    <Singleton Name="Me" Type="Trippin.Person">
        <NavigationPropertyBinding Path="BestFriend" Target="People" />
        <NavigationPropertyBinding Path="Friends" Target="People" />
    </Singleton>

    <FunctionImport Name="GetPersonWithMostFriends" Function="Trippin.GetPersonWithMostFriends" EntitySet="People" />

    <FunctionImport Name="GetNearestAirport" Function="Trippin.GetNearestAirport" EntitySet="Airports" />

    <ActionImport Name="ResetDataSource" Action="Trippin.ResetDataSource" />

</EntityContainer>

Los EntitySet no son más que las colecciones de entidades de un tipo concreto de las que dispone la API. Los EntitySet son análogos a una tabla de una base de datos relacional. Como vemos en este ejemplo, la primera EntitySet es “People”, justo igual como hemos accedido a entidades tipo “Person” al inicio de este artículo. Los endpoints siempre hacen referencia a los EntitySets, no a los EntityTypes. Podemos ver que “People” es una colección de “Person” y, además, vemos cómo es necesario definir también los nombres y tipos de las NavigationProperties.

Los Singleton, como bien indica el nombre, son instancias individuales de entidades dentro del servidor. Se definen aparte porque no son colecciones de entidades.

Finalmente, y de la misma forma que todo lo demás, para que una función o acción esté disponible desde el exterior debe estar incluida en el contenedor de entidades con la referencia a su nombre y al EntitySet que usan.

Conclusión y siguientes pasos

En este artículo hemos echado un primer vistazo al estándar OData v4, introduciendo el fichero de metadatos CSDL en OData v4 y qué información podemos sacar de él. Hemos hablado de las distintas entidades, tipos y propiedades, de las funciones y acciones y, finalmente, hemos comentado cómo encajan todas estas piezas en el contenedor de entidades para exponerlo al exterior. 

En la próxima entrega dedicada OData v4 hablaremos sobre todo de la sintaxis de consultas, sus parámetros y otros detalles que han quedado fuera del tintero en este artículo.

¡Hasta la próxima y feliz gestión de datos!

Si te ha parecido interesante este artículo, visita la categoría Data Engineering de nuestro blog para ver post similares a este y compártelo en redes con todos tus contactos. No olvides mencionarnos para poder conocer tu opinión @Damavisstudio. ¡Hasta pronto!
Lluc Sementé
Lluc Sementé
Artículos: 2