Support formation Rapports Power BI

API REST et connecteurs personnalisés

En bref

  • Objectifs : appeler une API REST protégée par jeton depuis Power Query ; paramétrer la requête et la transformer en fonction réutilisable ; empaqueter la connexion dans un connecteur personnalisé avec le Power Query SDK ; découvrir l'API REST Power BI.
  • Prérequis : maîtrise de Power Query et notions de langage M (partie 2) ; Visual Studio Code pour le connecteur.
  • Niveau : avancé.
  • Licence requise : aucune pour Power BI Desktop ; actualisation dans le service selon la source (passerelle pour un connecteur personnalisé).

Pourquoi ?

De nombreuses applications métier (CRM, outils de gestion, services en ligne) n'ont pas de connecteur Power BI natif mais exposent une API REST. Power Query sait l'interroger avec Web.Contents. Quand la même API sert à plusieurs rapports, un connecteur personnalisé évite de recopier le code et apparaît dans Obtenir des données comme un connecteur natif.

Notions clés

Le schéma d'appel

  1. Authentification : une requête POST envoie une clé d'API ou un couple identifiant et mot de passe et reçoit un jeton.
  2. Appel de l'API : une requête GET transmet le jeton dans l'en-tête Authorization: Bearer <jeton> et reçoit une réponse JSON.
  3. Mise en table : la réponse (souvent un champ data contenant une liste d'enregistrements) est convertie en table puis développée.

Paramètres de la requête

Les adresses sont stockées dans des paramètres Power Query, pour changer d'environnement sans toucher au code. Les valeurs de l'API de démonstration sont fournies par le formateur ; voici leur forme :

ParamètreExemple de valeur
paramAuthBaseURLhttps://<votre-api>/functions/v1
paramLoginPath/api/auth/token-from-key
paramApiBaseUrlhttps://<votre-api>/functions/v1
paramApiPath/api/tables

Pourquoi RelativePath ?

Dans la plupart des cas, le service Power BI ne sait pas actualiser une source « dynamique », dont l'adresse est calculée par la requête. Les options RelativePath et Query de Web.Contents font exception : l'URL de base reste fixe et le chemin variable est passé à part. Écrivez donc Web.Contents(ApiBaseUrl, [RelativePath = ApiPath]) plutôt que Web.Contents(ApiBaseUrl & ApiPath).

Les secrets

Ne saisissez jamais une clé d'API ou un mot de passe en clair dans une requête : il serait lisible par toute personne qui ouvre le PBIX ou le PBIP, et il partirait dans Git. Si l'API accepte la clé dans l'URL, l'option ApiKeyName de Web.Contents permet de la saisir dans la boîte de dialogue des informations d'identification plutôt que dans le code. Dans un connecteur personnalisé, déclarez un type d'authentification (Key, UsernamePassword, OAuth, Aad) et lisez la valeur avec Extension.CurrentCredential().

Pas à pas

Appeler l'API depuis Power Query

Téléchargez la solution complète :

[Bloc à reprendre de l'ancienne page dans l'éditeur : file | api-rest.zip]

1. Créez les quatre paramètres ci-dessus (Accueil > Gérer les paramètres > Nouveau paramètre), ainsi que paramEmail, paramPassword et paramAPIKey pour la démonstration. Ces trois valeurs sont stockées en clair dans le fichier : videz-les avant de diffuser le PBIX, ou passez au connecteur personnalisé.

2. Créez une requête vide (Nouvelle source > Requête vide), ouvrez l'Éditeur avancé et collez :

let
    // ====== Paramètres ======
    AuthBaseUrl = paramAuthBaseURL,
    LoginPath   = paramLoginPath,
    ApiBaseUrl  = paramApiBaseUrl,
    ApiPath     = paramApiPath,
    // Pour obtenir le jeton : adresse et mot de passe...
    Email    = paramEmail,
    Password = paramPassword,
    // ...ou clé d'API. Valeurs fournies par le formateur, jamais en clair dans un fichier diffusé
    APIKey = paramAPIKey,
    // ====== 1) Authentification : POST JSON, réponse JSON contenant le jeton ======
    LoginBody =
        Json.FromValue([
            email = Email,
            password = Password
        ]),
    LoginResponse =
        Json.Document(
            Web.Contents(
                AuthBaseUrl,
                [
                    RelativePath = LoginPath,
                    Headers = [
                        #"Content-Type" = "application/json",
                        #"X-API-Key" = APIKey,
                        Accept = "application/json"
                    ],
                    Content = LoginBody,
                    ManualStatusHandling = {400, 401, 403, 500}
                ]
            )
        ),
    // Adapter le champ selon la réponse réelle : token, access_token...
    Token =
        if Record.HasFields(LoginResponse, "token") then LoginResponse[token]
        else if Record.HasFields(LoginResponse, "access_token") then LoginResponse[access_token]
        else error "Champ token introuvable dans la réponse d'authentification.",
    // ====== 2) Appel de l'API avec le jeton Bearer ======
    WebContent =
        Web.Contents(
            ApiBaseUrl,
            [
                RelativePath = ApiPath,
                Headers = [
                    Authorization = "Bearer " & Token,
                    Accept = "application/json"
                ],
                Timeout = #duration(0, 0, 2, 0),
                ManualStatusHandling = {400, 401, 403, 500}
            ]
        ),
    ApiResponse = Json.Document(WebContent),
    // ====== 3) Mise en table ======
    data1 = ApiResponse[data],
    #"Converti en table" = Table.FromList(data1, Splitter.SplitByNothing(), null, null, ExtraValues.Error)
in
    #"Converti en table"

[Capture : éditeur Power Query, volet Requêtes avec les quatre paramètres et la requête API, aperçu de la table convertie]

3. Développez la colonne d'enregistrements (icône à deux flèches dans l'en-tête) et typez les colonnes.

4. Clic droit sur la requête > Créer une fonction, nommez-la fctAPI. Power Query propose comme arguments les paramètres utilisés par la requête ; vérifiez leur ordre dans la fenêtre d'appel.

[Capture : boîte de dialogue Créer une fonction, nom fctAPI, et volet Requêtes montrant la fonction sous le groupe de paramètres]

5. Appelez la fonction pour chaque point d'accès de l'API, en remplaçant /api/clients par le chemin voulu :

let
    Source = fctAPI("/api/clients", paramAuthBaseURL, paramLoginPath, paramApiBaseUrl)
in
    Source

Créer un connecteur personnalisé

Téléchargez le projet du connecteur :

[Bloc à reprendre de l'ancienne page dans l'éditeur : file | connecteur-personnalise.zip]

6. Dans Visual Studio Code, installez l'extension Power Query SDK, créez un dossier, puis dans la section Power Query SDK de l'Explorateur, Créer un projet d'extension.

7. Remplacez le contenu du fichier .pq par le squelette ci-dessous (connecteur « Client & Ventes », qui renvoie une table de navigation avec deux tables).

8. Testez avec le fichier .query.pq (clic droit > Évaluer le fichier Power Query actuel), après avoir défini les informations d'identification.

9. Générez le connecteur (tâche Build) : le fichier .mez est créé dans bin\AnyCPU\Debug.

10. Copiez le .mez dans [Documents]\Microsoft Power BI Desktop\Custom Connectors (créez le dossier s'il n'existe pas). Si le connecteur n'est pas signé, la politique de votre organisation peut autoriser, dans Options > Sécurité > Extensions de données, l'option « (Non recommandé) Autoriser le chargement d'une extension sans validation ou avertissement », puis redémarrez Desktop.

[Capture : fenêtre Obtenir des données de Power BI Desktop avec le connecteur « Client & Ventes » dans la catégorie Autre]

Exemple

Squelette du fichier .pq du connecteur. Il accepte trois modes d'authentification, dans cet ordre de priorité : clé d'API, identifiants client (client credentials), adresse et mot de passe.

section ClientVentes;

// ===========================
//  PUBLISH (entrée dans "Obtenir des données")
// ===========================
[DataSource.Kind="ClientVentes", Publish="ClientVentes.Publish"]
shared ClientVentes.Contents =
    Value.ReplaceType(ClientVentesImpl,
        type function (
            ApiBaseUrl as (type text meta [Documentation.FieldCaption="API Base URL, par ex. https://<votre-api>/functions/v1"]),
            ApiPathClients as (type text meta [Documentation.FieldCaption="Chemin Clients par ex. /api/clients", Publish.Preview.Default="/api/clients"]),
            ApiPathVentes as (type text meta [Documentation.FieldCaption="Chemin Ventes par ex. /api/ventes", Publish.Preview.Default="/api/ventes"]),
            AuthBaseUrl as (type text meta [Documentation.FieldCaption="Auth Base URL, par ex. https://<votre-api>", Publish.Preview.Default="https://<votre-api>"]),
            optional LoginPath as (type text meta [Documentation.FieldCaption="Login/Token Path", Publish.Preview.Default="/api/auth/token-from-key"]),
            optional APIKey as (type text meta [Documentation.FieldCaption="API Key (prioritaire si non vide)"]),
            optional ClientId as (type text meta [Documentation.FieldCaption="Client ID"]),
            optional ClientSecret as (type text meta [Documentation.FieldCaption="Client Secret"]),
            optional Scope as (type text meta [Documentation.FieldCaption="Scope"]),
            optional Email as (type text meta [Documentation.FieldCaption="Email"]),
            optional Password as (type text meta [Documentation.FieldCaption="Password"])
        ) as table
    );

// ===========================
//  IMPL
// ===========================
ClientVentesImpl =
(
    ApiBaseUrl as text,
    ApiPathClients as text,
    ApiPathVentes as text,
    AuthBaseUrl as text,
    optional LoginPath as nullable text,
    optional APIKey as nullable text,
    optional ClientId as nullable text,
    optional ClientSecret as nullable text,
    optional Scope as nullable text,
    optional Email as nullable text,
    optional Password as nullable text
) as table =>
let
    // ---- Defaults (adapter) ----
    _ApiBaseUrl     = ApiBaseUrl,
    _ApiPathClients = ApiPathClients,
    _ApiPathVentes  = ApiPathVentes,
    _AuthBaseUrl    = AuthBaseUrl,
    _LoginPath      = if IsNonEmpty(LoginPath) then LoginPath else "/api/auth/token-from-key",

    // ---- Auth precedence ----
    Headers = GetAuthHeaders(_AuthBaseUrl, _LoginPath, APIKey, ClientId, ClientSecret, Scope, Email, Password),

    // ---- Calls ----
    ClientsJson = CallApiJson(_ApiBaseUrl, _ApiPathClients, Headers),
    VentesJson  = CallApiJson(_ApiBaseUrl, _ApiPathVentes,  Headers),

    ClientsData = GetDataArray(ClientsJson),
    VentesData  = GetDataArray(VentesJson),

    TblClients  = Table.FromList(ClientsData, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
    TblClients2 =
        if Table.RowCount(TblClients) > 0 and Value.Is(TblClients{0}[Column1], type record)
        then Table.ExpandRecordColumn(TblClients, "Column1", Record.FieldNames(TblClients{0}[Column1]))
        else TblClients,

    TblVentes   = Table.FromList(VentesData, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
    TblVentes2  =
        if Table.RowCount(TblVentes) > 0 and Value.Is(TblVentes{0}[Column1], type record)
        then Table.ExpandRecordColumn(TblVentes, "Column1", Record.FieldNames(TblVentes{0}[Column1]))
        else TblVentes,

    // ---- Navigation table (Clients & Ventes) ----
    Nav = #table(
        type table [Name = text, Data = any, ItemKind = text, ItemName = text, IsLeaf = logical],
        {
            {"Clients", TblClients2, "Table", "Table", true},
            {"Ventes",  TblVentes2,  "Table", "Table", true}
        }
    )
    //Nav2 = Table.ToNavigationTable(Nav, {"Name"}, "Name", "Data", "ItemKind", "ItemName", "IsLeaf")
in
    Nav;

// ===========================
//  AUTH HELPERS
// ===========================
GetAuthHeaders =
(
    AuthBaseUrl as text,
    LoginPath as text,
    APIKey as nullable text,
    ClientId as nullable text,
    ClientSecret as nullable text,
    Scope as nullable text,
    Email as nullable text,
    Password as nullable text
) as record =>
let
    HasApiKey     = IsNonEmpty(APIKey),
    HasClientCred = IsNonEmpty(ClientId) and IsNonEmpty(ClientSecret),
    HasUserPass   = IsNonEmpty(Email) and IsNonEmpty(Password),

    Result =
        if HasApiKey then
            // Mode 1 : APIKey uniquement (prioritaire)
            [Accept="application/json", #"X-API-Key"=Text.From(APIKey)]
        else if HasClientCred then
            // Mode 2 : Client credentials -> token -> Bearer
            let
                Token = GetToken_ClientCredentials(AuthBaseUrl, LoginPath, Text.From(ClientId), Text.From(ClientSecret), Scope),
                TokenType = "Bearer"
            in
                [Accept="application/json", Authorization = TokenType & " " & Token]
        else if HasUserPass then
            // Mode 3 : Email/Password -> token -> Bearer
            let
                Token = GetToken_EmailPassword(AuthBaseUrl, LoginPath, Text.From(Email), Text.From(Password)),
                TokenType = "Bearer"
            in
                [Accept="application/json", Authorization = TokenType & " " & Token]
        else
            error "Aucune auth valide : renseigner APIKey, ou ClientId+ClientSecret, ou Email+Password."
in
    Result;

GetToken_ClientCredentials =
(AuthBaseUrl as text, LoginPath as text, ClientId as text, ClientSecret as text, Scope as nullable text) as text =>
let
    BodyText =
        Uri.BuildQueryString([
            grant_type    = "client_credentials",
            client_id     = ClientId,
            client_secret = ClientSecret,
            scope         = if Scope = null then "" else Text.From(Scope)
        ]),
    Resp =
        Json.Document(
            Web.Contents(
                AuthBaseUrl,
                [
                    RelativePath = LoginPath,
                    Headers = [
                        #"Content-Type" = "application/x-www-form-urlencoded",
                        Accept          = "application/json"
                    ],
                    Content = Text.ToBinary(BodyText),
                    ManualStatusHandling = {400,401,403,500}
                ]
            )
        ),
    Tok =
        if Record.HasFields(Resp, "access_token") then Text.From(Resp[access_token])
        else if Record.HasFields(Resp, "token") then Text.From(Resp[token])
        else error "Token introuvable (client_credentials)."
in
    Tok;

GetToken_EmailPassword =
(AuthBaseUrl as text, LoginPath as text, Email as text, Password as text) as text =>
let
    LoginBody = Json.FromValue([ email = Email, password = Password ]),
    Resp =
        Json.Document(
            Web.Contents(
                AuthBaseUrl,
                [
                    RelativePath = LoginPath,
                    Headers = [
                        #"Content-Type" = "application/json",
                        Accept          = "application/json"
                    ],
                    Content = LoginBody,
                    ManualStatusHandling = {400,401,403,500}
                ]
            )
        ),
    Tok =
        if Record.HasFields(Resp, "token") then Text.From(Resp[token])
        else if Record.HasFields(Resp, "access_token") then Text.From(Resp[access_token])
        else if Record.HasFields(Resp, "data") and Record.HasFields(Resp[data], "token") then Text.From(Resp[data][token])
        else error "Token introuvable (email/password)."
in
    Tok;

// ===========================
//  API HELPERS
// ===========================
CallApiJson =
(ApiBaseUrl as text, ApiPath as text, Headers as record) as any =>
let
    Bin =
        Web.Contents(
            ApiBaseUrl,
            [
                RelativePath = ApiPath,
                Headers = Headers,
                Timeout = #duration(0,0,2,0),
                ManualStatusHandling = {400,401,403,500}
            ]
        ),
    J = Json.Document(Bin)
in
    J;

GetDataArray = (ApiResponse as any) as list =>
    if Value.Is(ApiResponse, type record) and Record.HasFields(ApiResponse, "data") and Value.Is(ApiResponse[data], type list)
    then ApiResponse[data]
    else if Value.Is(ApiResponse, type list)
    then ApiResponse
    else {};

IsNonEmpty = (v as any) as logical =>
    v <> null and Text.Trim(Text.From(v)) <> "";

// ===========================
//  KIND / PUBLISH METADATA
// ===========================
ClientVentes = [
    TestConnection = (dataSourcePath) => { "ClientVentes.Contents", dataSourcePath },
    Authentication = [
        Anonymous = []   // Les secrets sont passés en paramètres dans cette version (APIKey/ClientId/Email...)
    ],
    Label = "Client & Ventes",
    SupportsEncryption = true
];

ClientVentes.Publish = [
    Beta = true,
    Category = "Other",
    ButtonText = { "Client & Ventes", "Connexion à l'API Client & Ventes" },
    SourceImage = ClientVentes.Icons,
    SourceTypeImage = ClientVentes.Icons,
    LearnMoreUrl = "https://<adresse-de-la-documentation-du-connecteur>",
    SupportsDirectQuery = false
];

ClientVentes.Icons = [
    Icon16 = { Extension.Contents("ClientVentes16.png"), Extension.Contents("ClientVentes20.png"), Extension.Contents("ClientVentes24.png"), Extension.Contents("ClientVentes32.png") },
    Icon32 = { Extension.Contents("ClientVentes32.png"), Extension.Contents("ClientVentes40.png"), Extension.Contents("ClientVentes48.png"), Extension.Contents("ClientVentes64.png") }
];

Cette version transmet les secrets en paramètres de la fonction avec une authentification Anonymous, ce qui convient à une démonstration mais pas à la production : les valeurs saisies restent visibles dans les étapes de la requête. Pour un usage réel, déclarez Key = [] ou UsernamePassword = [] dans le champ Authentication et lisez la valeur avec Extension.CurrentCredential().

L'API REST Power BI

Power BI expose lui-même une API REST pour automatiser l'administration : actualiser un modèle sémantique, exporter un rapport, lister les espaces de travail. L'appel demande un jeton Microsoft Entra ID (application inscrite ou principal de service). Exemple, déclencher l'actualisation d'un modèle sémantique dans un espace de travail (étendue Dataset.ReadWrite.All) :

POST https://api.powerbi.com/v1.0/myorg/groups/{groupId}/datasets/{datasetId}/refreshes
Authorization: Bearer <jeton>
Content-Type: application/json

{ "notifyOption": "MailOnFailure" }

La réponse 202 Accepted contient un en-tête Location pour suivre l'actualisation. Sur une capacité partagée, ces appels comptent dans la limite de 8 actualisations par jour.

Pièges et bonnes pratiques

  • Les requêtes POST de Web.Contents ne fonctionnent qu'avec des informations d'identification anonymes.
  • ManualStatusHandling permet de lire le corps d'une réponse d'erreur au lieu d'un échec immédiat : testez le code de retour avec Value.Metadata(WebContent)[Response.Status].
  • Le délai d'attente par défaut de Web.Contents est de 100 secondes ; l'option Timeout le modifie.
  • Gérez la pagination de l'API (paramètres offset et limit, ou lien vers la page suivante) avec une fonction récursive ou List.Generate (page Techniques avancées en M, partie 2).
  • Un connecteur personnalisé s'actualise dans le service via une passerelle de données locale sur laquelle il est installé.
  • Ne publiez jamais un fichier, une page ou un dépôt contenant une clé ou un mot de passe ; si cela arrive, révoquez la clé immédiatement.

À retenir

  • Web.Contents + RelativePath : une URL de base fixe pour que le service accepte l'actualisation.
  • Authentification, appel avec le jeton Bearer, mise en table : trois étapes.
  • Aucun secret en clair dans un fichier diffusé : authentification déclarée dans le connecteur, ou option ApiKeyName.
  • Power Query SDK dans VS Code : projet, test, génération du .mez.

Pour aller plus loin

  • Web.Contents. Source
  • Actualisation et sources de données dynamiques. Source
  • Vue d'ensemble du Power Query SDK. Source
  • Gestion de l'authentification dans un connecteur. Source
  • Extensibilité des connecteurs dans Power BI. Source
  • API REST Power BI. Source