diff --git a/src/libraries/Microsoft.PowerFx.Connectors/IPowerPlatformConnectorClient2.cs b/src/libraries/Microsoft.PowerFx.Connectors/IPowerPlatformConnectorClient2.cs new file mode 100644 index 0000000000..65722390e9 --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Connectors/IPowerPlatformConnectorClient2.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerFx.Connectors +{ + /// + /// Delegate for providing a bearer token for authentication. + /// + /// Cancellation token. + /// Token without "Bearer" scheme as the prefix. + public delegate Task PowerPlatformConnectorClient2BearerTokenProvider( + CancellationToken cancellationToken); + + public interface IPowerPlatformConnectorClient2 + { + /// + /// Sends an HTTP request to the Power Platform connector. + /// + /// HTTP method. + /// Operation path and query string. + /// Headers. + /// Content. + /// Diagnostic options. + /// Cancellation token. + /// . + Task SendAsync( + HttpMethod method, + string operationPathAndQuery, + IEnumerable>> headers, + HttpContent content, + PowerPlatformConnectorClient2DiagnosticOptions diagnosticOptions, + CancellationToken cancellationToken); + } +} diff --git a/src/libraries/Microsoft.PowerFx.Connectors/IPowerPlatformConnectorClient2Extensions.cs b/src/libraries/Microsoft.PowerFx.Connectors/IPowerPlatformConnectorClient2Extensions.cs new file mode 100644 index 0000000000..bdd9561351 --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Connectors/IPowerPlatformConnectorClient2Extensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerFx.Connectors +{ + public static class IPowerPlatformConnectorClient2Extensions + { + /// + /// Sends an HTTP request to the Power Platform connector. + /// + /// Client. + /// HTTP request message. URI path will be used as a relative path for the connector request. + /// Diagnostic options. + /// Cancellation token. + /// . + public static Task SendAsync( + this IPowerPlatformConnectorClient2 client, + HttpRequestMessage requestMessage, + PowerPlatformConnectorClient2DiagnosticOptions diagnosticOptions, + CancellationToken cancellationToken) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (requestMessage is null) + { + throw new ArgumentNullException(nameof(requestMessage)); + } + + return client.SendAsync( + requestMessage.Method, + requestMessage.RequestUri.PathAndQuery, + requestMessage.Headers, + requestMessage.Content, + diagnosticOptions, + cancellationToken); + } + } +} diff --git a/src/libraries/Microsoft.PowerFx.Connectors/OpenApiExtensions.cs b/src/libraries/Microsoft.PowerFx.Connectors/OpenApiExtensions.cs index 4d24231d31..7922d46efc 100644 --- a/src/libraries/Microsoft.PowerFx.Connectors/OpenApiExtensions.cs +++ b/src/libraries/Microsoft.PowerFx.Connectors/OpenApiExtensions.cs @@ -51,6 +51,17 @@ public static class OpenApiExtensions public static string GetAuthority(this OpenApiDocument openApiDocument, SupportsConnectorErrors errors) => GetUriElement(openApiDocument, (uri) => uri.Authority, errors); private static string GetUriElement(this OpenApiDocument openApiDocument, Func getElement, SupportsConnectorErrors errors) + { + var uri = GetFirstServerUri(openApiDocument, errors); + if (uri is null) + { + return null; + } + + return getElement(uri); + } + + internal static Uri GetFirstServerUri(this OpenApiDocument openApiDocument, SupportsConnectorErrors errors) { if (openApiDocument?.Servers == null) { @@ -72,8 +83,7 @@ private static string GetUriElement(this OpenApiDocument openApiDocument, Func> list, bool isNumber) = openApiParameter.GetEnumValues(); if (list != null && list.Any()) @@ -1205,7 +1215,7 @@ internal static Dictionary GetParameterMap(thi { // https://github.com/microsoft/OpenAPI.NET/issues/533 // https://github.com/microsoft/Power-Fx/pull/1987 - https://github.com/microsoft/Power-Fx/issues/1982 - // api-version, x-ms-api-version, X-GitHub-Api-Version... + // api-version, x-ms-api-version, X-GitHub-Api-Version... if (prm.Key.EndsWith("api-version", StringComparison.OrdinalIgnoreCase)) { fv = FormulaValue.New(dtv.GetConvertedValue(TimeZoneInfo.Utc).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); diff --git a/src/libraries/Microsoft.PowerFx.Connectors/PowerPlatformConnectorClient2.cs b/src/libraries/Microsoft.PowerFx.Connectors/PowerPlatformConnectorClient2.cs new file mode 100644 index 0000000000..3836f3dad9 --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Connectors/PowerPlatformConnectorClient2.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.OpenApi.Models; + +namespace Microsoft.PowerFx.Connectors +{ + /// + /// Client for invoking operations of Power Platform connectors. + /// + public class PowerPlatformConnectorClient2 : IPowerPlatformConnectorClient2 + { + private readonly string _baseUrlStr; + private readonly HttpMessageInvoker _httpMessageInvoker; + private readonly PowerPlatformConnectorClient2BearerTokenProvider _tokenProvider; + private readonly string _environmentId; + + /// + /// Initializes a new instance of the class. + /// + /// Document used for extracting the base URL. + /// HTTP message invoker. + /// Bearer token provider. + /// Environment ID. + public PowerPlatformConnectorClient2( + OpenApiDocument document, + HttpMessageInvoker httpMessageInvoker, + PowerPlatformConnectorClient2BearerTokenProvider tokenProvider, + string environmentId) + : this(GetBaseUrlFromOpenApiDocument(document), httpMessageInvoker, tokenProvider, environmentId) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Base URL for requests. + /// HTTP message invoker. + /// Bearer token provider. + /// Environment ID. + public PowerPlatformConnectorClient2( + Uri baseUrl, + HttpMessageInvoker httpMessageInvoker, + PowerPlatformConnectorClient2BearerTokenProvider tokenProvider, + string environmentId) + { + this._baseUrlStr = GetBaseUrlStr(baseUrl ?? throw new ArgumentNullException(nameof(baseUrl))); + this._httpMessageInvoker = httpMessageInvoker ?? throw new ArgumentNullException(nameof(httpMessageInvoker)); + this._tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); + this._environmentId = environmentId ?? throw new ArgumentNullException(nameof(environmentId)); + + static string GetBaseUrlStr(Uri uri) + { + var str = uri.GetLeftPart(UriPartial.Path); + + // Note (shgogna): Ensure the base URL does NOT end with "/". + // This will allow us to concatenate the operation path to the base URL + // without worrying about the "/". + str = str.TrimEnd('/'); + return str; + } + } + + // + public Task SendAsync( + HttpMethod method, + string operationPathAndQuery, + IEnumerable>> headers, + HttpContent content, + PowerPlatformConnectorClient2DiagnosticOptions diagnosticOptions, + CancellationToken cancellationToken) + { + if (operationPathAndQuery is null) + { + throw new ArgumentNullException(nameof(operationPathAndQuery)); + } + + var uri = this.CombineBaseUrlWithOperationPathAndQuery(operationPathAndQuery); + + if (!uri.AbsoluteUri.StartsWith(this._baseUrlStr, StringComparison.Ordinal)) + { + throw new ArgumentException("Path traversal detected during combination of base URL path and operation path.", nameof(operationPathAndQuery)); + } + + return this.InternalSendAsync( + method, + uri, + headers, + content, + diagnosticOptions, + cancellationToken); + } + + private async Task InternalSendAsync( + HttpMethod method, + Uri uri, + IEnumerable>> headers, + HttpContent content, + PowerPlatformConnectorClient2DiagnosticOptions diagnosticOptions, + CancellationToken cancellationToken) + { + var authToken = await this._tokenProvider(cancellationToken).ConfigureAwait(false); + + using (var req = new HttpRequestMessage(method, uri)) + { + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + this.AddDiagnosticHeaders(diagnosticOptions, req); + + foreach (var header in headers) + { + req.Headers.Add(header.Key, header.Value); + } + + req.Content = content; + return await this._httpMessageInvoker.SendAsync(req, cancellationToken).ConfigureAwait(false); + } + } + + private Uri CombineBaseUrlWithOperationPathAndQuery(string operationPathAndQuery) + { + if (operationPathAndQuery.StartsWith("/", StringComparison.Ordinal)) + { + return new Uri(this._baseUrlStr + operationPathAndQuery); + } + else + { + return new Uri(this._baseUrlStr + "/" + operationPathAndQuery); + } + } + + private void AddDiagnosticHeaders( + PowerPlatformConnectorClient2DiagnosticOptions diagnosticOptions, + HttpRequestMessage req) + { + var userAgent = string.IsNullOrWhiteSpace(diagnosticOptions?.UserAgent) + ? $"PowerFx/{PowerPlatformConnectorClient.Version}" + : $"{diagnosticOptions.UserAgent} PowerFx/{PowerPlatformConnectorClient.Version}"; + + var clientRequestId = string.IsNullOrWhiteSpace(diagnosticOptions?.ClientRequestId) + ? Guid.NewGuid().ToString() + : diagnosticOptions.ClientRequestId; + + // CorrelationID can be the same as ClientRequestID + var correlationId = string.IsNullOrWhiteSpace(diagnosticOptions?.CorrelationId) + ? clientRequestId + : diagnosticOptions.CorrelationId; + + req.Headers.Add("User-Agent", userAgent); + req.Headers.Add("x-ms-user-agent", userAgent); + req.Headers.Add("x-ms-client-environment-id", $"/providers/Microsoft.PowerApps/environments/{this._environmentId}"); + req.Headers.Add("x-ms-client-request-id", clientRequestId); + req.Headers.Add("x-ms-correlation-id", correlationId); + + if (!string.IsNullOrWhiteSpace(diagnosticOptions?.ClientSessionId)) + { + req.Headers.Add("x-ms-client-session-id", diagnosticOptions.ClientSessionId); + } + + if (!string.IsNullOrWhiteSpace(diagnosticOptions?.ClientTenantId)) + { + req.Headers.Add("x-ms-client-tenant-id", diagnosticOptions.ClientTenantId); + } + + if (!string.IsNullOrWhiteSpace(diagnosticOptions?.ClientObjectId)) + { + req.Headers.Add("x-ms-client-object-id", diagnosticOptions.ClientObjectId); + } + } + + private static Uri GetBaseUrlFromOpenApiDocument(OpenApiDocument document) + { + ConnectorErrors errors = new ConnectorErrors(); + Uri uri = document.GetFirstServerUri(errors); + + if (uri is null) + { + errors.AddError("Swagger document doesn't contain an endpoint"); + } + + if (errors.HasErrors) + { + throw new PowerFxConnectorException(string.Join(", ", errors.Errors)); + } + + return uri; + } + } +} diff --git a/src/libraries/Microsoft.PowerFx.Connectors/PowerPlatformConnectorClient2DiagnosticOptions.cs b/src/libraries/Microsoft.PowerFx.Connectors/PowerPlatformConnectorClient2DiagnosticOptions.cs new file mode 100644 index 0000000000..a4b29564e5 --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Connectors/PowerPlatformConnectorClient2DiagnosticOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.PowerFx.Connectors +{ + public class PowerPlatformConnectorClient2DiagnosticOptions + { + public string ClientSessionId { get; set; } + + public string ClientRequestId { get; set; } + + public string ClientTenantId { get; set; } + + public string ClientObjectId { get; set; } + + public string CorrelationId { get; set; } + + public string UserAgent { get; set; } + } +}