Dans le cadre d’un projet d’intégration SAP pour un client, j’ai été amené à consommer des APIs OData exposées par SAP Gateway depuis une application C#/.NET 8. L’objectif : récupérer des données métier (factures, pièces jointes, écritures comptables) pour les synchroniser avec d’autres systèmes via Azure.
C’est dans ce contexte que j’ai découvert les forces — et les nombreux pièges — d’OData appliqué à l’écosystème SAP. Cet article est un retour d’expérience concret, pas un tutoriel générique.
OData (Open Data Protocol) est un standard ouvert créé par Microsoft en 2007, puis normalisé par OASIS en 2014. Il ne s’agit pas d’un protocole à part entière : c’est une convention REST enrichie, construite au-dessus de HTTP.
L’idée centrale est simple et puissante : exposer des données comme une base de données interrogeable via des URLs HTTP. Au lieu de multiplier les endpoints (/products/expensive, /products/by-category…), OData laisse le client décider de ce qu’il veut, directement dans l’URL :
GET /Products?$filter=Price gt 100&$orderby=Name&$top=10
Il existe deux versions majeures :
Sans OData, pour chaque besoin de filtrage, tri ou pagination, le développeur backend doit créer un endpoint spécifique, gérer les cas dans le code serveur, puis versionner à chaque nouveau besoin côté client.
Résultat : une explosion du nombre d’endpoints, une documentation difficile à maintenir, et des allers-retours constants entre équipes.
Avec OData, c’est le client qui pilote :
GET /invoices/paidGET /invoices?$filter=Status eq 'PAID'
GET /invoices/page/3GET /invoices?top=20& skip=40
GET /invoices/sorted-by-dateGET /invoices?$orderby=Date desc
GET /invoices/{id}/items puis jointureGET /invoices?$expand=Items
Pour un ERP comme SAP qui expose des milliers d’entités métier, c’est une approche idéale : un seul protocole, une seule convention, pour toutes les données.
La vraie force d’OData ne réside pas seulement dans son uniformité, mais dans ce qu’elle permet d’éviter côté backend : plus besoin de multiplier les endpoints pour chaque combinaison de filtre, tri ou pagination demandée par les clients. Le protocole expose un ensemble de paramètres de requête standardisés, préfixés par $, qui déportent cette logique côté client — c’est lui qui décide ce qu’il récupère, et comment.
Sur le projet, ces paramètres ont permis de réduire drastiquement le nombre d’appels vers SAP, en ne récupérant que les champs nécessaires et en filtrant directement côté serveur plutôt qu’en post-traitement C#. Voici les plus utilisés dans ce contexte.
L’utilisation de $select évite de rapatrier 50 champs quand on n’en a besoin que de 3. C’est indispensable pour les performances réseau.
GET /Invoices?$select=Id,Amount,Date
$filter accepte quatre familles d’opérateurs, qu’on peut combiner librement dans une même requête : des opérateurs de comparaison pour évaluer une valeur, des connecteurs logiques pour enchaîner plusieurs conditions, ainsi que des fonctions dédiées aux chaînes de caractères et aux dates lorsque la comparaison simple ne suffit pas.
GET /Invoices?$filter=Amount gt 1000 and Status eq 'PAID'
$orderby trie les résultats sur un ou plusieurs champs, chacun pouvant être précisé en ordre croissant (asc, par défaut) ou décroissant (desc).
GET /Invoices?$orderby=Date desc,Amount asc
$top limite le nombre de résultats retournés et $skip indique combien d’éléments ignorer avant de commencer à les lire ; combinés, ils permettent de paginer manuellement une collection.
Attention : SAP peut plafonner ou ignorer $top sans le signaler. Voir la section retour d’expérience.
GET /Invoices?$top=20&$skip=40 → page 3 (index 0)
Cette commande est l’équivalent d’un JOIN SQL : retourne la commande et ses lignes en un seul appel, évitant le problème N+1.
GET /Orders?$expand=OrderItems
Cela retourne le nombre total d’éléments en plus des résultats. C’est notamment utile pour la pagination côté UI.
GET /Invoices?$count=true
Ici, on demande le schéma XML complet : toutes les entités, leurs champs, leurs types et leurs relations. C’est la documentation réelle de l’API, souvent plus fiable que la documentation officielle SAP. À consulter en premier dès qu’on commence à intégrer un service.
GET /sap/opu/odata/sap/API_CV_ATTACHMENT_SRV/$metadata
SAP Gateway est le composant SAP qui expose les données ERP via OData. Toutes les APIs SAP publiques (disponibles sur le SAP API Business Hub) sont en OData v2 ou v4 (SAP API Business Hub : https://api.sap.com).
Quelques exemples d’APIs OData SAP courantes :
Un point important : SAP Fiori consomme lui-même ces APIs OData pour afficher ses interfaces. Ce qu’on appelle depuis C# est exactement ce que l’interface Fiori utilise, ce qui peut servir pour vérifier un comportement attendu en inspectant les requêtes réseau dans le navigateur.
C’est l’approche la plus flexible. Elle est indispensable quand les librairies tierces ne gèrent pas les spécificités SAP (headers particuliers, formats de données non standards).
var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")));
client.DefaultRequestHeaders.Add("sap-client", "100");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var url = "https://sap.example.com/sap/opu/odata/sap/API_SUPPLIER_INVOICE_SRV/A_SuplrInvcItemPurOrdRef"
+ "?$filter=FiscalYear eq '2024'"
+ "&$select=SupplierInvoice,DocumentItemText,AmountInTransactionCurrency"
+ "&$top=100";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
Conseil : toujours logger l’URL complète en mode debug. La grande majorité des erreurs OData vient d’une URL mal construite (encodage manquant, guillemets oubliés, opérateur incorrect).
Une librairie .NET qui abstrait la construction des URLs et offre une API fluent.
var odataClient = new ODataClient("https://sap.example.com/odata/");
var invoices = await odataClient.For("A_SuplrInvcItemPurOrdRef")
.Filter(x => x["FiscalYear"] == "2024")
.Select("SupplierInvoice", "AmountInTransactionCurrency")
.Top(100)
.FindEntriesAsync();
Génère des classes C# fortement typées à partir du $metadata. Pratique en théorie, mais la génération de code depuis un $metadata SAP complexe est souvent laborieuse et produit des classes difficiles à maintenir. À réserver aux projets avec peu d’entités et un $metadata stable.
La méthode d’authentification à utiliser dépend du contexte : environnement de dev, système on-premise ou SAP S/4HANA Cloud. Voici les principales options rencontrées sur ce type de projet.
Avant toute opération de modification, SAP exige un token CSRF. Le flux est en deux étapes :
Étape 1 — Récupérer le token
var fetchRequest = new HttpRequestMessage(HttpMethod.Get, baseUrl + "/$metadata");
fetchRequest.Headers.Add("X-CSRF-Token", "Fetch");
var fetchResponse = await client.SendAsync(fetchRequest);
var csrfToken = fetchResponse.Headers.GetValues("X-CSRF-Token").FirstOrDefault();
Étape 2 — Utiliser le token dans la mutation
var postRequest = new HttpRequestMessage(HttpMethod.Post, url);
postRequest.Headers.Add("X-CSRF-Token", csrfToken);
postRequest.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
var postResponse = await client.SendAsync(postRequest);
Sans ce token, SAP retourne une erreur 403 Forbidden avec le message CSRF token validation failed.
C’est probablement la partie la plus frustrante de l’intégration OData SAP.
Ces pièges concernent avant tout OData v2 : le format /Date(timestamp)/ est une convention historique de sérialisation JSON (héritée du format OData verbose de Microsoft), que SAP a conservée. OData v4 utilise en principe le format ISO 8601 standard, mais dans la pratique de nombreux services SAP exposés en v2 — très répandus sur S/4HANA on-premise — continuent de renvoyer ce format propriétaire, d’où l’importance de vérifier la version du service avant d’écrire son code de désérialisation.
SAP OData v2 sérialise les dates dans un format propriétaire :
{
"PostingDate": "/Date(1704067200000)/"
}
Ce timestamp est en millisecondes depuis l’epoch Unix (1er janvier 1970 UTC). System.Text.Json ne sait pas désérialiser ce format nativement — il faut un JsonConverter custom :
public class SapDateConverter : JsonConverter<DateTime?>
{
private static readonly Regex SapDateRegex = new(@"/Date\((-?\d+)\)/");
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (string.IsNullOrEmpty(value))
return null;
var match = SapDateRegex.Match(value);
if (!match.Success)
return null;
var ms = long.Parse(match.Groups[1].Value);
if (ms == -62135596800000L)
return null;
return DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime;
}
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
{
if (value == null)
{
writer.WriteNullValue();
return;
}
var ms = new DateTimeOffset(value.Value, TimeSpan.Zero).ToUnixTimeMilliseconds();
writer.WriteStringValue($"/Date({ms})/");
}
}
La syntaxe pour le filtrage utilise le mot-clé datetime de cette manière :
$filter=PostingDate ge datetime'2024-01-01T00:00:00'
Mais ce n’est pas une date ISO 8601 directe comme on pourrait l’attendre. Cette syntaxe est propre à OData v2 et n’est pas toujours documentée clairement.
SAP retourne /Date(-62135596800000)/ pour les champs date non renseignés. Cette valeur correspond au 1er janvier 0001 en .NET. Il faut la détecter explicitement et la traiter comme null, sinon on obtient des dates aberrantes dans les données.
SAP stocke généralement les dates en UTC+0, mais les retourne sans indication de fuseau horaire explicite. Si votre application est dans un fuseau différent, des décalages d’un jour peuvent apparaître sur les filtres. C’est pour cela qu’il faut toujours travailler en UTC côté C# pour les comparaisons.
Après plusieurs mois à intégrer différents services OData SAP sur ce projet, certains comportements se sont révélés aussi pénibles que récurrents — et aucun n’est mentionné dans la documentation officielle ni sur le SAP API Business Hub. Ils ne se découvrent qu’en production ou à force de tests manuels, souvent après avoir perdu du temps à chercher une erreur côté C# qui n’existait pas. Voici les principaux pièges rencontrés sur ce projet.
Certains $filter syntaxiquement valides sont tout simplement ignorés par SAP sans retourner d’erreur : la réponse contient des résultats, mais pas ceux attendus. C’est particulièrement trompeur. Toujours valider un filtre en comparant manuellement les résultats retournés avec ceux attendus.
En OData v2, SAP utilise substringof — l’inverse de contains en termes de paramètres :
$filter=contains(Name, 'Dupont')$filter=substringof('Dupont', Name)
Utiliser contains avec SAP OData v2 retourne souvent une erreur ou aucun résultat selon l’endpoint.
Certains services SAP plafonnent le nombre de résultats (souvent à 1000) indépendamment de la valeur passée dans $top. Ce maximum n’est généralement pas documenté. Pour récupérer l’ensemble des données, il faut implémenter la pagination via $skip ou via le lien @odata.nextLink retourné dans la réponse.
Le nom technique d’un champ dans l’URL OData peut être totalement différent du libellé affiché dans l’interface Fiori. La source de vérité reste le $metadata — pas la documentation ni l’interface utilisateur.
Même si une relation est définie dans le $metadata, certains endpoints SAP ne supportent pas $expand dessus. Le serveur retourne alors une erreur 400 ou 501. Il faut dans ce cas faire des appels séparés et effectuer la jointure côté C#
OData est un protocole élégant qui simplifie considérablement l’exposition et la consommation de données métier. Dans l’écosystème SAP, il est incontournable. Mais son implémentation SAP comporte suffisamment de spécificités pour justifier d’être prudent.
Résumé des points clés à retenir :
L’investissement dans une bonne couche d’abstraction OData (client HTTP configuré, converter de dates, gestion du CSRF token) paie rapidement dès qu’on intègre plusieurs services SAP sur un même projet.