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
- Authentification : une requête POST envoie une clé d'API ou un couple identifiant et mot de passe et reçoit un jeton.
- 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. - Mise en table : la réponse (souvent un champ
datacontenant 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ètre | Exemple de valeur |
paramAuthBaseURL | https://<votre-api>/functions/v1 |
paramLoginPath | /api/auth/token-from-key |
paramApiBaseUrl | https://<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
SourceCré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.Contentsne fonctionnent qu'avec des informations d'identification anonymes. ManualStatusHandlingpermet de lire le corps d'une réponse d'erreur au lieu d'un échec immédiat : testez le code de retour avecValue.Metadata(WebContent)[Response.Status].- Le délai d'attente par défaut de
Web.Contentsest de 100 secondes ; l'optionTimeoutle modifie. - Gérez la pagination de l'API (paramètres
offsetetlimit, ou lien vers la page suivante) avec une fonction récursive ouList.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.