OData avec SAP depuis C#

Publié par Charles DOUANGDARA
Catégorie : .Net
10/11/2026

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 :

  • OData v2 : encore très répandue, notamment chez SAP
  • OData v4 : plus puissante et plus moderne, adoptée progressivement par SAP S/4HANA

 

Le problème qu’OData résout

 

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 :

 

  • Filtrer
    • Sans OData : GET /invoices/paid
    • Avec OData : GET /invoices?$filter=Status eq 'PAID'

 

  • Paginer
    • Sans OData : GET /invoices/page/3
    • Avec OData : GET /invoices?top=20& skip=40

 

  • Trier
    • Sans OData : GET /invoices/sorted-by-date
    • Avec OData : GET /invoices?$orderby=Date desc

 

  • Entités liées
    • Sans OData : GET /invoices/{id}/items puis jointure
    • Avec OData : GET /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.

 

Les capacités clés du protocole

 

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.

 

$select — Choisir ses colonnes

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 — Filtrer les données

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

  • Comparaison : eq, ne, gt, lt, ge, le
  • Logique : and, or, not
  • Fonctions string : startswith, endswith, contains (ou substringof en v2 SAP)
  • Fonctions date : year(), month(), day()

 

GET /Invoices?$filter=Amount gt 1000 and Status eq 'PAID'

 

$orderby — Trier

$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 / $skip — Paginer

$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)

 

$expand — Naviguer entre entités liées

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

 

$count — Compter les résultats

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

 

$metadata — Comprendre le schéma de l’API

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

 

OData dans l’écosystème SAP

 

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 :

  • API_SUPPLIER_INVOICE_SRV : factures fournisseurs
  • API_JOURNAL_ENTRY_SRV : écritures comptables
  • API_CV_ATTACHMENT_SRV : pièces jointes sur objets métier
  • API_BUSINESS_PARTNER_SRV : données tiers (clients, fournisseurs)
  • API_PRODUCT_SRV : référentiel produits

 

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.

 

Comment appeler un endpoint OData depuis C#

 

Option 1 — HttpClient brut (recommandé pour SAP)

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

 

Option 2 — Simple.OData.Client

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();

 

  • Avantage : lisible, fluent, moins de manipulation de chaînes.
  • Inconvénient : les particularités SAP (CSRF token, formats date, headers customs) nécessitent quand même du code additionnel.

 

Option 3 — Microsoft.OData.Client

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.

 

Authentification : Basic Auth, mTLS, OAUTH2, CSRF Token

 

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.

  • Basic Auth : dev/test — simple mais à éviter absolument en production
  • mTLS (certificat client) : production via Azure APIM — le client présente un certificat au lieu d’un mot de passe
  • OAuth2 / SAML : SAP S/4HANA Cloud, via SAP Identity Provider
  • X-CSRF-Token : obligatoire pour tout appel POST, PUT, PATCH, DELETE

 

Le CSRF Token : une spécificité SAP incontournable

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.

 

Les pièges sur les champs Date

 

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.

 

Le format /Date(timestamp)/

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})/"); 
    } 
}

 

Filtrer sur une date en OData v2

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.

 

La valeur sentinelle pour « date vide »

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.

 

Le problème de timezone

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.

 

Ce que SAP ne dit pas — Retour d’expérience

 

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.

 

Les filtres ignorés silencieusement

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.

 

substringof au lieu de contains

En OData v2, SAP utilise substringof — l’inverse de contains en termes de paramètres :

  • OData v4 standard : $filter=contains(Name, 'Dupont')
  • OData v2 SAP : $filter=substringof('Dupont', Name)

 

Utiliser contains avec SAP OData v2 retourne souvent une erreur ou aucun résultat selon l’endpoint.

 

$top peut être ignoré ou plafonné

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.

 

Les noms de champs ne correspondent pas toujours à Fiori

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.

 

$expand n’est pas toujours disponible

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#

 

Conclusion et conseils pratiques

 

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 :

  • Commencer toujours par inspecter le $metadata avant d’écrire du code
  • Utiliser HttpClient brut pour garder le contrôle sur les headers et l’URL
  • Implémenter un JsonConverter custom pour les dates SAP dès le début
  • Tester chaque filtre dans Postman avant de le coder
  • Ne pas faire confiance à la documentation SAP seule : valider sur $metadata + essai/erreur
  • Logger l’URL complète en mode debug : la plupart des erreurs viennent de là
  • Prévoir la pagination : $top seul ne suffit pas pour des jeux de données importants

 

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.