Blog tech

OData pour une API standardisée

Rédigé par Numa Claudel | 9 février 2017

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.

Découverte du protocole

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 :

  • il faut une URL racine qui va lister les ressources proposées par le service
  • il faut aussi une URL de description du service, qui va contenir la description de chaque ressource ainsi que les URLs permettant de les manipuler
  • il faudra aussi que chaque ressource bénéficie d’une URL de description
  • chaque ressource est accessible par les URLs décrites dans la description du service
  • des paramètres peuvent être passés pour formater la réponse (du type $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.

Quelques exemples

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ée
  • value : 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 ressource

Il 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 personnes

Je 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.

Proposer un service OData

Il est possible de proposer 3 niveaux d’API OData :

  • le niveau minimum qui peut se décomposer en 2 sous niveaux : un service qui permet simplement de consulter les données (lecture seule) ou une API qui permet la consultation et les opérations sur les objets
  • le niveau intermédiaire, qui est censé répondre aux opérations du niveau minimum, plus permettre des opérations de limitation (du genre select, top)
  • le niveau avancé, qui regroupe donc les 2 niveaux précédents, et doit proposer une page de description du service, répondre en XML et JSON, permettre d’ordonner les données, …

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 personnes

D’autres paramètres sont utilisables, vous trouverez différentes possibilités dans les documentations.

Conclusion

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.