Récemment un client voulait accéder à des jeux de données brutes venant de son application Web et qu’elles soient directement consultables à partir d’un tableur. Pour ce faire, proposer une API aurait pu convenir, mais il avait entendu parlé d’un protocole qui semblait tout indiqué pour ce type d’échanges : OData.
OData, comme Open Data, est un protocole standardisé apportant des règles pour définir et consommer des APIs. Il a été élaboré par Microsoft, puis à été repris par OASIS. Plusieurs versions se sont succédées pour arriver maintenant à la version 4.
Une API OData permet de faire de simples requêtages équivalents à un select *
en SQL, récupérer des informations précises (comme par exemple récupérer simplement la valeur d’un attribut), mais aussi de répondre à des demandes plus élaborées, comme une recherche sur tous les attributs en fonction d’une valeur donnée. Le protocole ne s’arrête pas là, puisqu’il prévoit la possibilité d’effectuer des opérations de création / modification / suppression sur les ressources présentées.
Le site odata.org permet de se documenter sur la normalisation du protocole, mais offre aussi des tutoriels, exemples et services en libre accès. Cela permet de découvrir l’esprit du protocole et la forme des différentes requêtes.
Il y a même un tutoriel prévu à partir de Postman, qui permet ainsi de faire des essais tout en découvrant. J’ai trouvé celui-ci plutôt utile pour apprendre, car il apporte des exemples de requêtes et permet de voir directement ce que retourne un service OData.
Pour parfaire cet apprentissage, il reste la consultation de la documentation, dont les 3 principaux volets sont : Protocol, URL Conventions et Common Schema Definition Language. Elle est assez difficile à consulter et en même temps très précise, en tout cas elle est la base pour établir un service OData respectant cette “norme”.
Un service OData se doit d’être RESTful, c’est à dire qu’il doit se comporter de la même manière que ce que nous avons l’habitude d’implémenter avec une application Rails : un POST
avec des paramètres sur l’URL d’une ressource devra créer un objet du type de la ressource. De la même manière que DELETE
, PATCH
, GET
doivent agir sur la ressource correspondant à l’URL.
Après avoir consulté toutes ces ressources instructives, les bases qu’il faut déjà en retenir sont :
$parameter=
, exemples: $select
, $top
, $orderby
)Une distinction est faite entre un service OData en lecture seule, qui propose seulement la possibilité de récupérer des données, et un service complet, qui va permettre la récupération de données ainsi que des opérations de manipulation d’objets. Pour aller plus loin donc, il faudra en plus implémenter les requêtes de types POST
, DELETE
et PATCH
. Il est également possible de proposer d’autres types d’opérations telles que des Actions
et Functions
, qui sont des opérations prédéfinies.
Enfin, un service OData peut supporter les ETag
pour permettre d’ajouter des conditions aux requêtes.
Je vous propose de vous présenter quelques échanges qu’il est possible de faire avec un service OData, ainsi que les réponses attendues. Je vais pour cela me servir du tutoriel avec Postman.
Disons que notre application propose un service OData, accéder à la racine du service doit nous lister les ressources accessibles :
// http://services.odata.org/V4/TripPinService
{
"@odata.context": "http://services.odata.org/V4/TripPinService/$metadata",
"value": [
{
"name": "Photos",
"kind": "EntitySet",
"url": "Photos"
},
{
"name": "People",
"kind": "EntitySet",
"url": "People"
},
{
"name": "Airlines",
"kind": "EntitySet",
"url": "Airlines"
},
{
"name": "Airports",
"kind": "EntitySet",
"url": "Airports"
},
{
"name": "Me",
"kind": "Singleton",
"url": "Me"
},
{
"name": "GetNearestAirport",
"kind": "FunctionImport",
"url": "GetNearestAirport"
}
]
}
Ce service nous expose 6 ressources : 4 EntitySet
(qui sont les ressources de base), 1 Singleton
et 1 Function
. On a aussi une clé @odata.context
qui nous indique l’URL de la description du service.
<!-- http://services.odata.org/V4/TripPinService/$metadata -->
<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="Microsoft.OData.SampleService.Models.TripPin" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EnumType Name="PersonGender">
<Member Name="Male" Value="0" />
<Member Name="Female" Value="1" />
<Member Name="Unknown" Value="2" />
</EnumType>
<ComplexType Name="City">
<Property Name="CountryRegion" Type="Edm.String" Nullable="false" />
<Property Name="Name" Type="Edm.String" Nullable="false" />
<Property Name="Region" Type="Edm.String" Nullable="false" />
</ComplexType>
.
.
.
<EntityType Name="Photo" HasStream="true">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.Int64" Nullable="false">
<Annotation Term="Org.OData.Core.V1.Permissions">
<EnumMember>Org.OData.Core.V1.Permission/Read</EnumMember>
</Annotation>
</Property>
<Property Name="Name" Type="Edm.String" />
<Annotation Term="Org.OData.Core.V1.AcceptableMediaTypes">
<Collection>
<String>image/jpeg</String>
</Collection>
</Annotation>
</EntityType>
<EntityType Name="Person" OpenType="true">
<Key>
<PropertyRef Name="UserName" />
</Key>
<Property Name="UserName" Type="Edm.String" Nullable="false">
<Annotation Term="Org.OData.Core.V1.Permissions">
<EnumMember>Org.OData.Core.V1.Permission/Read</EnumMember>
</Annotation>
</Property>
<Property Name="FirstName" Type="Edm.String" Nullable="false" />
<Property Name="LastName" Type="Edm.String" Nullable="false" />
<Property Name="Emails" Type="Collection(Edm.String)" />
<Property Name="AddressInfo" Type="Collection(Microsoft.OData.SampleService.Models.TripPin.Location)" />
<Property Name="Gender" Type="Microsoft.OData.SampleService.Models.TripPin.PersonGender" />
<Property Name="Concurrency" Type="Edm.Int64" Nullable="false">
<Annotation Term="Org.OData.Core.V1.Computed" Bool="true" />
</Property>
<NavigationProperty Name="Friends" Type="Collection(Microsoft.OData.SampleService.Models.TripPin.Person)" />
<NavigationProperty Name="Trips" Type="Collection(Microsoft.OData.SampleService.Models.TripPin.Trip)" ContainsTarget="true" />
<NavigationProperty Name="Photo" Type="Microsoft.OData.SampleService.Models.TripPin.Photo" />
</EntityType>
.
.
.
</Schema>
</edmx:DataServices>
</edmx:Edmx>
J’ai retiré ici une bonne partie de la description pour des raisons de clarté. Ce que l’on peut déjà observer c’est que la description est donnée en XML. On observe aussi une liste de spécificités pour certains attributs, une description des EntityType
avec leurs attributs respectifs et les propriétés de navigation (person.friends
par exemple). On peut dire que la description est complète et très précise.
Maintenant, demandons la liste des personnes :
// http://services.odata.org/V4/TripPinService/People
{
"@odata.context": "http://services.odata.org/V4/TripPinService/$metadata#People",
"@odata.nextLink": "http://services.odata.org/V4/TripPinService/People?%24skiptoken=8",
"value": [
{
"@odata.id": "http://services.odata.org/V4/TripPinService/People('russellwhyte')",
"@odata.etag": "W/\"08D44F6077701C43\"",
"@odata.editLink": "http://services.odata.org/V4/TripPinService/People('russellwhyte')",
"UserName": "russellwhyte",
"FirstName": "Russell",
"LastName": "Whyte",
"Emails": [
"Russell@example.com",
"Russell@contoso.com"
],
"AddressInfo": [
{
"Address": "187 Suffolk Ln.",
"City": {
"CountryRegion": "United States",
"Name": "Boise",
"Region": "ID"
}
}
],
"Gender": "Male",
"Concurrency": 636220723105373251
},
{
"@odata.id": "http://services.odata.org/V4/TripPinService/People('scottketchum')",
"@odata.etag": "W/\"08D44F6077701C43\"",
"@odata.editLink": "http://services.odata.org/V4/TripPinService/People('scottketchum')",
"UserName": "scottketchum",
"FirstName": "Scott",
"LastName": "Ketchum",
"Emails": [
"Scott@example.com"
],
"AddressInfo": [
{
"Address": "2817 Milton Dr.",
"City": {
"CountryRegion": "United States",
"Name": "Albuquerque",
"Region": "NM"
}
}
],
"Gender": "Male",
"Concurrency": 636220723105373251
},
.
.
.
]
}
Voyons voir ce que nous avons :
@odata.context
: le lien vers la description de la ressource@odata.nextLink
: un lien vers la ressource paginéevalue
: la liste des personnes avec leurs attributs respectifs@odata.id
: l’identifiant exposé par le service OData@odata.etag
: la version qui est présentée@odata.editLink
: le lien d’édition de la ressourceIl est également possible de demander ces données en version XML, en passant le paramètre $format
.
Voici quelques URLs supplémentaires avec des paramètres :
/V4/TripPinService/People('russellwhyte')/UserName
pour n’avoir que l’attribut nom de la ressource/V4/TripPinService/People('russellwhyte')/UserName/$value
pour n’avoir que la valeur du nom de la ressource/V4/TripPinService/People?$filter=LastName eq 'Whyte'
pour n’avoir que les personnes de nom Whyte/V4/TripPinService/People?$orderby=LastName desc
pour avoir les personnes triées par leur nom dans l’ordre décroissant/V4/TripPinService/People?$top=5
pour n’avoir que les 5 premières personnes/V4/TripPinService/People/$count
pour avoir le nombre total de personnesJe vais arrêter là les exemples, mais je vous invite à consulter les tutoriels ou à jouer avec les services en libres accès mis à disposition sur le site odata.org.
Il est possible de proposer 3 niveaux d’API OData :
select
, top
)Voici les descriptions détaillées de ces différents niveaux de conformité.
Développer cette API de A à Z allait être assez fastidieux. Le plus simple alors était de trouver une gem à intégrer à l’application. Heureusement, il existe différentes solutions prêtes à l’emploi pour proposer un service OData. En voici une liste non exhaustive.
On peut malheureusement remarquer qu’aucune solution en Ruby n’est listée… dommage. Après avoir un petit peu fouillé sur le net, nous avons trouvé cette gem : odata_server.
Cette gem permet d’exposer un service OData en lecture seule à partir d’une application Rails et c’était suffisant pour les besoins de notre client. Le seul hic c’est que nous avions besoin que le service soit exposé en version 4 du protocole OData. Il restait donc à procéder à une mise à jour de cette gem pour arriver à nos fins.
Après un peu de réorganisation et de mises à jour du protocole, en voici la résultante. Le protocole OData n’est pas supporté complètement et il reste encore quelques mises à jour, mais une bonne partie est fonctionnelle et ça fait le job comme on dit.
Ajouter cette gem à une application Rails vous permettra donc de requêter vos ressources au travers d’un navigateur, Postman, un tableur ou avec tout autre client OData qui supporte la version 4 du protocole.
Et voici comment exposer vos modèles avec un service OData donc :
Ajouter ceci à votre Gemfile :
gem 'odata_server'
Lancer un bundle install
, puis (disons que vous voulez exposer un modèle Person
) ajoutez un initialiseur odata_server.rb
contenant :
classes = [
Person
]
ar_schema = OData::ActiveRecordSchema::Base.new('AROData', classes: classes)
OData::Edm::DataServices.schemas << ar_schema
Ajoutez ensuite la route à partir de laquelle vous voulez que votre service soit accessible :
mount OData::Engine, at: '/services/OData'
Redémarrez votre serveur, vous pouvez maintenant consulter votre service et la liste des personnes via ces URLs :
/services/OData
racine du service/services/OData/$metadata
description du service/services/OData/People
la liste des personnes/services/OData/People?$top=10
les 10 premières personnes/services/OData/People/$count
le nombre de personnesD’autres paramètres sont utilisables, vous trouverez différentes possibilités dans les documentations.
Je pense que le protocole OData est assez complexe au premier abord, mais il a le mérite de poser un standard, qui si on le respecte permet de proposer une API dont le comportement est connu. Un client voulant consommer une API OData saura d’emblée les requêtes à envoyer pour récupérer la réponse voulue.
L’équipe Synbioz.
Libres d’être ensemble.