1. Un peu de contexte
  2. Des connaissances pas à pas
  3. Le code M avec Power BI Desktop : challenges connus
  4. L’API Scan depuis Power BI Desktop : mon premier code M
  5. Vers la Migration de mon code M dans un connecteur oauth2

Un peu de contexte

Je voulais partager, au travers de cet article, mon approche pas à pas de la migration de mon code M pour réaliser les appels API Power BI (Scanner API) vers un « Custom Connector » et les challenges techniques que l’on peut rencontrer si l’on n’est pas un expert Power Query, Azure, voire même Power BI. Ne vous faites pas d’illusions, cela n’a rien d’une approche « low code » / « no code » cela ne sera que partie remise 😊.

Je rappelle que l’objectif de ces appels était de mettre à disposition dans un rapport des usages (API des Events), de la documentation des rapports existants (Scanner API) sur la plateforme pour les utilisateurs. La motivation du développement par un code en M pour répondre à ce besoin était piloté par le contexte client, car, avec l’arrivée de Microsoft Fabric, cette approche pourrait être revue avec une toute nouvelle architecture, c’est une certitude ! Enfin les solutions hybrides usuelles telles que PowerShell ou Power Automate ne pouvaient, dans mon cas, pas être ma cible au moment de ce développement. En effet, les outils de mon environnement que j’avais à disposition rendaient l’approche en M plus accessible et me donnaient davantage d’autonomie.

Des connaissances pas à pas

Avant même de réaliser ces appels API j’avais besoin comme toujours de commencer par la création d’une Application Azure [1] [2], chose très bien décrite par la documentation officielle de Microsoft. Pour ma part, je souhaitais utiliser cette App en tant que principal de service. C’est à ce moment là qu’il est important de comprendre la gestion des étendues au niveau de ces Applications car souvent, pour certaines, celles-ci se trouvent déjà en service dans l’organisation mais pas toujours nécessaires. Le fait étant que tout paramétrage dans le portail d’administration POWER BI au niveau des API/principaux de service tend à « annuler » la configuration des étendues dans Azure, en d’autre termes ils ne sont pas considérés.

Gardez à l’esprit que pour initier le développement de votre futur connecteur, il vous faudra avoir traité un minima les points suivants :

  • Installer le client Power BI Desktop évidemment.
  • Les droits de création d’une Application dédiée dans Azure ou d’une App existante. Pensez à créer le groupe de sécurité comme défini sur les documentations liées.
  • Activer les principaux de service, les métadonnées et expressions pour la restitution des entités de la Scan API que nous souhaitons récupérer [4]. Pensez à positionner votre groupe de sécurité sur les Workspaces Power BI.

  • Un accès à la Gateway Power BI de votre site pour installer le futur connecteur.

En définitive, si vous débutez sur ce sujet des API REST Power BI, vous constaterez rapidement que les notions ne vont pas s’y limiter. Il vous faudra des droits avancés et une bonne compréhension de la création de applications Azure, des modes d’autorisations délégués ou applicatifs. Les étendues qui, quant à elles, sont dites pertinentes « uniquement lors de l’authentification via un jeton d’accès administrateur délégué standard et non présent lors de l’authentification via un principal de service » [4].

Le code M avec Power BI Desktop : challenges connus

Maintenant que la partie configuration a été exposée, je me suis inspirée des différents codes M de la communauté et j’ai adapté la partie liée à l’appel de la Scan API.

Je remercie la riche littérature de Chris Webb par exemple, qui, au travers de ces articles, indiquait que pour rafraichir le code M il fallait tout d’abord écrire le code de façon compréhensible pour l’authentification. Si votre endpoint n’est pas celui attendu, vous aurez des erreurs sur le Service Power BI. Une implémentation de type oauth2 s’avère également être la solution pour rafraichir dans le Service de façon sécurisée ces appels API. Quoi qu’il en soit la Gateway restera nécessaire pour votre développement.

En référence aux articles de Chris Webb :

  • Le paramètre RelativePath devra contenir les 2 endpoints https://api.powerbi.com et https://login.microsoftonline.com/{tenant id}/oauth2/token pour permettre le refresh dans le Service Power BI [5].
  • Si vous souhaitez tester votre code M développé sur Power BI Desktop, vous avez la possibilité de tester le refresh dans le Service en configurant votre source sur la Gateway et en activant l’option « Skip Connexion ». Je crois me rappeler avoir déjà vu des erreurs liées au niveau de la confidentialité, alors je préfère vous dire que dans ce cas, attribuer les mêmes niveaux à chaque source (chaque endpoint) peut éviter d’autres erreurs de type « Combine ». Pour la configuration, l’authentification de type Anonyme est à renseigner.
  • En référence aux articles de Chris Webb, j’argumente à nouveau les points clés qu’il souligne. Vu que cela n’est pas sécure de mettre les identifiants dans le code M de son modèle sémantique, il faut développer un connecteur personnalisé en oauth2 qui, lui, ne fonctionnera que depuis la Gateway [6].

L’API Scan depuis Power BI Desktop : mon premier code M

Je partage ici mon code M développé à partir de Power BI Desktop pour tester l’appel à l’API Scan depuis mon client (mon App Azure) en direct auprès de la ressource Power BI. Il est certainement perfectible car je ne suis pas experte Power Query mais il fonctionne.

let
// Récupération du Token par un SPN
Token =
let
ClientId = "xxxxxxxxxxx",
ClientSecret = "xxxxxxxxxxx",
Uri = "https://login.microsoftonline.com/{tenantid}/oauth2/token",
Resource = "https://analysis.windows.net/powerbi/api",
GrantType = "client_credentials",
Options = [
Content = Text.ToBinary(
Uri.BuildQueryString(
[
client_id = ClientId,
client_secret = ClientSecret,
resource = Resource,
grant_type = GrantType
]
)
),
Headers = [Accept = "application/json"],
ManualStatusHandling = {400}
],
Response = Json.Document(Web.Contents(Uri, Options)),
Source = Response[access_token]
in
Source,

// Récupération des WS du tenant
WorkspaceIds =
let
Options = [
RelativePath = "v1.0/myorg/groups",
Headers = [Authorization = "Bearer " & Token]
],
Workspaces = Json.Document(Web.Contents("https://api.powerbi.com", Options)),
Values = Workspaces[value],
Table = Table.FromList(Values, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ResultWS = Table.ExpandRecordColumn(Table, "Column1", {"id"}, {"id"})
in
ResultWS,


// Appel à la Scan API pour la valeur ScanID
ScanId =
let
WorkspaceIdsWithQuotes = Table.ReplaceValue(
WorkspaceIds,
each [id],
each "'" & [id] & "'",
Replacer.ReplaceValue,
{"id"}
),
WorkspaceIdList = Table.ToList(WorkspaceIdsWithQuotes),
Content = Text.ToBinary("{'workspaces': [" & Text.Combine(WorkspaceIdList, ",") & "]}"),
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/getInfo",
Headers = [Authorization = "Bearer " & Token, #"Content-Type" = "application/json"],
Content = Content,
Query = [
lineage = "True",
datasourceDetails = "True",
datasetSchema = "True",
datasetExpressions = "True",
getArtifactUsers = "True"
]
],
ResultScanId = Json.Document(Web.Contents("https://api.powerbi.com", Options))[id]
in
ResultScanId,


// Appel à la Scan API récupération du statut, passage de la valeur ScanID

GetStatus = (RetryCount as number) =>
let
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/scanStatus/" & ScanId,
Headers = [Authorization = "Bearer " & Token],
Query = [retry = Number.ToText(RetryCount)]
],
ResultStatus = Json.Document(Web.Contents("https://api.powerbi.com", Options))

in
ResultStatus,

RetryCount = 0,
GetStatusOrRetry = (RetryCount as number) =>
let
RetryCount = RetryCount + 1,
Status = GetStatus(RetryCount),
Result =
if Status[status] = "Succeeded" or RetryCount >= 30 then
Status
else
Function.InvokeAfter(() => @GetStatusOrRetry(RetryCount), #duration(0, 0, 0, 1))

in
Result,

Status = GetStatusOrRetry(RetryCount),
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/scanResult/" & Status[id]
Headers = [Authorization = "Bearer " & Token]
],


// Résultat de la Scan API
ResultScan = Json.Document(Web.Contents("https://api.powerbi.com", Options))
workspaces = ResultScan[workspaces]

in
workspaces

// Vous pouvez développer en suivant vos colonnes…….

Si vous chercher vos points de terminaison respectifs pour adapter le code, sachez qu’ils sont tous listés au niveau de votre Application Azure ici :

Vers la Migration de mon code M dans un connecteur oauth2

Dans cette partie le challenge est un peu différent, en effet j’ai pu m’aider des nombreuses contributions communautaires afin de réaliser les tests de mon connecteur.

A titre d’exemple, Microsoft propose un bon tutoriel sur GitHub pour commencer. Le connecteur bien connu de Miguel Escobar reste aussi une très bonne référence pour réaliser 90 % de votre connecteur [7] [8] [9] [10]. Ce dernier est développé avec une authentification Microsoft Entra ID qui permet de récupérer un jeton intégré. Je ne peux en dire davantage sur les limites de la sécurité de ce flux.

Dans notre cas, nous aurons à adapter mon appel en mode principal de service (grant_type= « client_credentials ») pour le cas de l’API Scan ; la littérature proposant souvent la version de type grant_type= « authorization_code ».

Voici les points clés que je peux noter pour la partie connecteur :

  • Vous penchez sur le schéma oauth2 [13] à implémenter de type client_credentials.

·       Ouvrir le connecteur de Miguel avec VS code comme modèle ou à partir d’un projet vierge afin d’y retrouver les blocs de code suivants :   

o   La définition de la source avec l’étape de « Test connexion »[14],      

o  Les fonctions StartLogin(), FinishLogin() [15]. La fonction TokenMethod() utilisée dans l’appel de la fonction Finishlogin().Le bloc lié à l’interface de connecteur commenté comme étant « UI Export définition »,

o   Le bloc lié à l’exécution de votre code M pour vos appels API, ici « ScanResult ».

StartLogin, FinishLogin et TokenMethod pour mon connecteur Oauth2

J’ai recensé ici deux méthodes pour faire mon connecteur :

Soit mon authentification est dite « Anonyme », et dans ce cas mon connecteur ne fait pas apparaitre les fonctions StartLogin et FinishLogin. Il suffit donc d’implémenter directement l’appel à l’API en transférant mon développement de Power BI Desktop vers le bloc ResultScan du connecteur. Le fichier MyconnectorScanAnonymous.pq du connecteur ressemble alors à ceci :

section MyconnectorScanAnonymous;
// Definition de la Source

[DataSource.Kind = "MyconnectorScanAnonymous", Publish = "MyconnectorScanAnonymous.Publish"]
shared MyconnectorScanAnonymous.Contents = (optional message as text) => ResultScan;


// Authentification avec le TestConnection
MyconnectorScanAnonymous = [
TestConnection = (dataSourcePath) => {"MyconnectorScanAnonymous.Contents"},
Authentication = [Anonymous = []],
Label = Extension.LoadString("DataSourceLabel")
];

// Code Scan API
ResultScan =
let
// Récupération du Token par un SPN
Token =
let
ClientId = "xxxxxxxxxxx",
ClientSecret = "xxxxxxxxxxx",
Uri = "https://login.microsoftonline.com/{tenantid}/oauth2/token",
Resource = "https://analysis.windows.net/powerbi/api",
GrantType = "client_credentials",
Options = [
Content = Text.ToBinary(
Uri.BuildQueryString(
[
client_id = ClientId,
client_secret = ClientSecret,
resource = Resource,
grant_type = GrantType
]
)
),
Headers = [Accept = "application/json"],
ManualStatusHandling = {400}
],
Response = Json.Document(Web.Contents(Uri, Options)),
Source = Response[access_token]
in
Source,


// Récupération des WS du tenant
WorkspaceIds =
let
Options = [
RelativePath = "v1.0/myorg/groups",
Headers = [Authorization = "Bearer " & Token]
],

Workspaces = Json.Document(Web.Contents("https://api.powerbi.com", Options)),
Values = Workspaces[value],
Table = Table.FromList(Values, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ResultWS = Table.ExpandRecordColumn(Table, "Column1", {"id"}, {"id"})
in
ResultWS,


// Appel à la Scan API pour la valeur ScanID
ScanId =
let
WorkspaceIdsWithQuotes = Table.ReplaceValue(
WorkspaceIds,
each [id],
each "'" & [id] & "'",
Replacer.ReplaceValue,
{"id"}

),
WorkspaceIdList = Table.ToList(WorkspaceIdsWithQuotes),
Content = Text.ToBinary("{'workspaces': [" & Text.Combine(WorkspaceIdList, ",") & "]}"),
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/getInfo",
Headers = [Authorization = "Bearer " & Token, #"Content-Type" = "application/json"],
Content = Content,
Query = [
lineage = "True",
datasourceDetails = "True",
datasetSchema = "True",
datasetExpressions = "True",
getArtifactUsers = "True"
]
],
ResultScanId = Json.Document(Web.Contents("https://api.powerbi.com", Options))[id]
in
ResultScanId,


// Appel à la Scan API récupération du statut, passage de la valeur ScanID
GetStatus = (RetryCount as number) =>
let
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/scanStatus/" & ScanId,
Headers = [Authorization = "Bearer " & Token],
Query = [retry = Number.ToText(RetryCount)]
],
ResultStatus = Json.Document(Web.Contents("https://api.powerbi.com", Options))
in
ResultStatus,
RetryCount = 0,
GetStatusOrRetry = (RetryCount as number) =>
let
RetryCount = RetryCount + 1,
Status = GetStatus(RetryCount),
Result =
if Status[status] = "Succeeded" or RetryCount >= 30 then
Status
else
Function.InvokeAfter(() => @GetStatusOrRetry(RetryCount), #duration(0, 0, 0, 1))
in
Result,

Status = GetStatusOrRetry(RetryCount),
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/scanResult/" & Status[id],
Headers = [Authorization = "Bearer " & Token]
],

// Résultat de la Scan API
ResultScan = Json.Document(Web.Contents("https://api.powerbi.com", Options)),
workspaces = ResultScan[workspaces]
in
workspaces ;

// Vous pouvez développer en suivant vos colonnes…….


// Export UI : cette partie provient du tutoriel HelloWord , ainsi que les images embarquées.
MyconnectorScanAnonymous.Publish = [
Beta = true,
ButtonText = {Extension.LoadString("FormulaTitle"), Extension.LoadString("FormulaHelp")},
SourceImage = Image1.Icons,
SourceTypeImage = Image1.Icons
];
MyconnectorScanAnonymous.Icons = [
Icon16 = {
Extension.Contents("Image16.png"),
Extension.Contents("Image20.png"),
Extension.Contents("Image24.png"),
Extension.Contents("Image32.png")
},
Icon32 = {
Extension.Contents("Image32.png"),
Extension.Contents("Image40.png"),
Extension.Contents("Image48.png"),
Extension.Contents("Image64.png")
}
];

Soit mon authentification est dite « oauth2 », alors elle fait apparaitre dans la signature de l’authentification les fonctions StartLogin et FinishLogin. En réalité, StartLogin est utile dans le cas où il y une interaction avec l’utilisateur : elle démarre le flux avec l’interface de Microsoft pour vous permettre d’y renseigner votre compte. Cela s’apparente finalement au flux de type « code_autorization » que vous retrouverez dans la documentation [13].

J’aime bien l’idée d’afficher cette pop-up comme une possible barrière d’authentification avant de lancer l’appel à la fonction FinishLogin, mais en aucun cas je n’utilise le code généré (pas de passage du code dans l’appel au endpoint du token). A savoir je n’ai pas testé le cas où si l’appel StartLogin échoue. Pour simplifier la fonction, vous pouvez toutefois renvoyer une valeur de type Record qui n’aura aucun impact sur votre connecteur et qui lancera le deuxième flux pour la récupération du token.

Ici un exemple de fichier MyconnectorScanOauth2.pq :

section MyconnectorScanOauth2;
// Definition de la source, de l'authentification oauth2
[DataSource.Kind = "MyconnectorScanOauth2", Publish = "MyconnectorScanOauth2.Publish"]
shared MyconnectorScanOauth2.Contents = (optional message as text) => ResultScan;

MyconnectorScanOauth2= [
TestConnection = (dataSourcePath) => {"MyconnectorScanOauth2.Contents", dataSourcePath},
Authentication = [OAuth = [StartLogin = StartLogin, FinishLogin = FinishLogin]],
Label = Extension.LoadString("DataSourceLabel")
];


// Appel à la Scan API
ResultScan =
let
Token =
let
CurrentCredential = Record.Field(Extension.CurrentCredential(), "access_token")
in
CurrentCredential,

// Récupération des WS du tenant
WorkspaceIds =
let
Options = [
RelativePath = "v1.0/myorg/groups",
Headers = [Authorization = "Bearer " & Token]
],
Workspaces = Json.Document(Web.Contents("https://api.powerbi.com", Options)),
Values = Workspaces[value],
Table = Table.FromList(Values, Splitter.SplitByNothing(), null, null, ExtraValues.Error),
ResultWS = Table.ExpandRecordColumn(Table, "Column1", {"id"}, {"id"})
in
ResultWS,


// Appel à la Scan API pour la valeur ScanID
ScanId =
let
WorkspaceIdsWithQuotes = Table.ReplaceValue(
WorkspaceIds,
each [id],
each "'" & [id] & "'",
Replacer.ReplaceValue,
{"id"}
),
WorkspaceIdList = Table.ToList(WorkspaceIdsWithQuotes),
Content = Text.ToBinary("{'workspaces': [" & Text.Combine(WorkspaceIdList, ",") & "]}"),
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/getInfo",
Headers = [Authorization = "Bearer " & Token, #"Content-Type" = "application/json"],
Content = Content,
Query = [
lineage = "True",
datasourceDetails = "True",
datasetSchema = "True",
datasetExpressions = "True",
getArtifactUsers = "True"
]
],
ResultScanId = Json.Document(Web.Contents("https://api.powerbi.com", Options))[id]
in
ResultScanId,


// Appel à la Scan API récupération du statut, passage de la valeur ScanID
GetStatus = (RetryCount as number) =>
let
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/scanStatus/" & ScanId,
Headers = [Authorization = "Bearer " & Token],
Query = [retry = Number.ToText(RetryCount)]
],

ResultStatus = Json.Document(Web.Contents("https://api.powerbi.com", Options))
in
ResultStatus,
RetryCount = 0,
GetStatusOrRetry = (RetryCount as number) =>

let
RetryCount = RetryCount + 1,
Status = GetStatus(RetryCount),
Result =
if Status[status] = "Succeeded" or RetryCount >= 30 then
Status
else
Function.InvokeAfter(() => @GetStatusOrRetry(RetryCount), #duration(0, 0, 0, 1))
in
Result,
Status = GetStatusOrRetry(RetryCount),
Options = [
RelativePath = "v1.0/myorg/admin/workspaces/scanResult/" & Status[id],
Headers = [Authorization = "Bearer " & Token]
],



// Résultat de la Scan API
ResultScan = Json.Document(Web.Contents("https://api.powerbi.com", Options)),
workspaces = ResultScan[workspaces]
in
workspaces ;
// Vous pouvez développer en suivant vos colonnes…….


// Définition des fonctions StartLogin, FinishLogin et TokenMethod
StartLogin = (resourceUrl, state, display) =>
let
windowWidth = 720,
windowHeight = 1024,
AuthorizeUrl = "https://login.microsoftonline.com/{tenantid}/oauth2/v2.0/authorize?"
& Uri.BuildQueryString(
[
client_id = "xxxxxxxxxxxxxx",
redirect_uri = "https://oauth.powerbi.com/views/oauthredirect.html",
scope = "https://graph.microsoft.com/User.Read",
state = state,
response_type = "code",
response_mode = "query",
login = "login"
]
)
in
[
LoginUri = AuthorizeUrl,
CallbackUri = "https://oauth.powerbi.com/views/oauthredirect.html",
WindowHeight = windowHeight,
WindowWidth = windowWidth,
Context = null
];


FinishLogin = (context, callbackUri, state) =>
let
Parts = Uri.Parts(callbackUri)[Query]
in
TokenMethod();



TokenMethod = () =>
let
tokenResponse = Web.Contents(
"https://login.microsoftonline.com/{tenantid}/oauth2/token",
[
Content = Text.ToBinary(
Uri.BuildQueryString(
[
client_id = "xxxxxxx",
client_secret = "xxxxxxx",
Resource = "https://analysis.windows.net/powerbi/api",
grant_type = "client_credentials"
]
)
),

Headers = [
#"Content-type" = "application/x-www-form-urlencoded",
#"Accept" = "application/json"
],
ManualStatusHandling = {400}
]
),
body = Json.Document(tokenResponse),
token =
if (Record.HasFields(body, {"error", "error_description"})) then
error Error.Record(body[error], body[error_description], body)
else
body
in
token;



// Export UI : cette partie provient du tutoriel HelloWord , ainsi que les images embarquées.
MyconnectorScanOauth2.Publish = [
Beta = true,
ButtonText = {Extension.LoadString("FormulaTitle"), Extension.LoadString("FormulaHelp")},
SourceImage = MyconnectorScanOauth2.Icons,
SourceTypeImage = MyconnectorScanOauth2.Icons
];

MyconnectorScanOauth2.Icons = [
Icon16 = {
Extension.Contents("Image16.png"),
Extension.Contents("Image20.png"),
Extension.Contents("Image24.png"),
Extension.Contents("Image32.png")
},

Icon32 = {
Extension.Contents("Image32.png"),
Extension.Contents("Image40.png"),
Extension.Contents("Image48.png"),
Extension.Contents("Image64.png")
}
];

Les points non implémentés

Je n’ai pas implémenté durant mes tests le consentement admin que l’on retrouve sur le schéma du flux oauth2. J’ai fait toutefois l’approbation depuis mon navigateur en entrant la requête type (URL de l’appel StartLogin) :

https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?client_id=xxxxxxxx&redirect_uri=https://oauth.powerbi.com/views/oauthredirect.html&scope=https://graph.microsoft.com/User.Read&response_type=code&response_mode=query&login=login

Votre page Web vous invitera à entrer votre Login et MDP et vous demandera votre consentement.

Ici une illustration de ce que vous pourrez avoir à votre écran lors du consentement :

Comme dit précédemment, je pourrais simplifier la partie StartLogin avec un retour de valeur de type Record.

Pour la partie FinishLogin, je n’ai fait que retourner le token définit dans ma fonction TokenMethod(). Le code retourné dans le callback n’a pas été utilisé : une nouvelle fois, je n’ai pas d’utilisateur dans cet usage même si pour le « Fun » j’affiche à l’interface la demande d’authentification Microsoft. Si vous le souhaitez, vous pouvez implémenter le flux oauth2 de type « code_autorization ».

[1] https://learn.microsoft.com/fr-fr/entra/identity-platform/howto-create-service-principal-portal
[2] https://learn.microsoft.com/en-us/power-bi/developer/embedded/register-app?tabs=customers
[3] https://learn.microsoft.com/fr-fr/rest/api/power-bi/
[4] https://learn.microsoft.com/fr-fr/rest/api/power-bi/admin/workspace-info-post-workspace-info
[5] https://blog.crossjoin.co.uk/page/44/?cat=-1
[6] https://blog.crossjoin.co.uk/2021/08/29/connecting-to-rest-apis-with-oauth2-authentication-in-power-query-power-bi/
[7] https://github.com/microsoft/DataConnectors/blob/master/samples/Github/github.pq
[8] https://github.com/migueesc123
[9] https://jussiroine.com/2019/02/building-a-custom-connector-for-power-bi-that-supports-oauth2-to-visualize-my-wellness-data/
[10] https://dev.to/kenakamu/develop-power-bi-custom-connector-microsoft-graph-connector-with-aad-1j31
[11] https://learn.microsoft.com/en-us/power-query/install-sdk
[12] https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols
[13] https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow
[14] https://learn.microsoft.com/en-us/power-query/samples/trippin/9-testconnection/readme
[15] https://learn.microsoft.com/en-us/power-query/samples/github/readme

Avatar de gaelle leclercq

Published by

Categories:

Laisser un commentaire

Concevoir un site comme celui-ci avec WordPress.com
Commencer