diff --git a/src/Kiota.Builder/GenerationLanguage.cs b/src/Kiota.Builder/GenerationLanguage.cs index 7f2696e7e3..dc5aa6839c 100644 --- a/src/Kiota.Builder/GenerationLanguage.cs +++ b/src/Kiota.Builder/GenerationLanguage.cs @@ -10,5 +10,9 @@ public enum GenerationLanguage Go, Ruby, Dart, - HTTP + HTTP, + // Emits PowerShell cmdlet classes that call a Kiota-generated C# client. Bypasses the + // CodeDOM/refiner/writer pipeline, same shape as plugin generation; see + // KiotaBuilder.GeneratePowerShellWrapperAsync. + PowerShellWrapper } diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs index 6cac83b4fb..8a8f809cac 100644 --- a/src/Kiota.Builder/KiotaBuilder.cs +++ b/src/Kiota.Builder/KiotaBuilder.cs @@ -27,6 +27,7 @@ using Kiota.Builder.Manifest; using Kiota.Builder.OpenApiExtensions; using Kiota.Builder.Plugins; +using Kiota.Builder.PowerShellWrapper; using Kiota.Builder.Refiners; using Kiota.Builder.Settings; using Kiota.Builder.WorkspaceManagement; @@ -204,7 +205,7 @@ private static string NormalizeApiManifestPath(RequestInfo request, string? base } StopLogAndReset(sw, $"step {++stepId} - checking whether the output should be updated - took"); - if (shouldGenerate && generating) + if (shouldGenerate && generating && config.Language != GenerationLanguage.PowerShellWrapper) { modelNamespacePrefixToTrim = GetDeeperMostCommonNamespaceNameForModels(openApiDocument); } @@ -215,10 +216,15 @@ private static string NormalizeApiManifestPath(RequestInfo request, string? base CleanupOperationIdForPlugins(openApiDocument); } - // Create Uri Space of API - sw.Start(); - openApiTree = CreateUriSpace(openApiDocument); - StopLogAndReset(sw, $"step {++stepId} - create uri space - took"); + // Create Uri Space of API. PowerShellWrapperGenerationService walks document.Paths + // directly and never reads this tree, so building it would be wasted work for that + // language. + if (config.Language != GenerationLanguage.PowerShellWrapper) + { + sw.Start(); + openApiTree = CreateUriSpace(openApiDocument); + StopLogAndReset(sw, $"step {++stepId} - create uri space - took"); + } } return (stepId, openApiTree, shouldGenerate, readResult?.Diagnostic); @@ -275,6 +281,27 @@ public async Task GeneratePluginAsync(CancellationToken cancellationToken, }, cancellationToken).ConfigureAwait(false); } + /// + /// Generates PowerShell wrapper cmdlets straight from the filtered OpenAPI document, + /// bypassing the CodeDOM/refiner/writer pipeline. Sibling to GeneratePluginAsync, for + /// GenerationLanguage.PowerShellWrapper; the work happens in PowerShellWrapperGenerationService. + /// + /// The cancellation token + /// Whether the generated code was updated or not + public async Task GeneratePowerShellWrapperAsync(CancellationToken cancellationToken) + { + return await GenerateConsumerAsync(async (sw, stepId, openApiTree, ct) => + { + if (openApiDocument is null) + throw new InvalidOperationException("The OpenAPI document must be loaded before generating the PowerShell wrapper cmdlets"); + sw.Start(); + var wrapperService = new PowerShellWrapperGenerationService(openApiDocument, config, logger); + await wrapperService.GenerateAsync(ct).ConfigureAwait(false); + StopLogAndReset(sw, $"step {++stepId} - generate PowerShell wrapper cmdlets - took"); + return stepId; + }, cancellationToken).ConfigureAwait(false); + } + /// /// Generates the code from the OpenAPI document /// diff --git a/src/Kiota.Builder/PowerShellWrapper/CmdletEmitter.cs b/src/Kiota.Builder/PowerShellWrapper/CmdletEmitter.cs new file mode 100644 index 0000000000..e44a3ec743 --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/CmdletEmitter.cs @@ -0,0 +1,679 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Kiota.Builder.PowerShellWrapper; + +// Emits the wrapper cmdlet shapes from section 8 of the design spec, one cmdlet class per +// selected OpenAPI operation: mandatory path-id parameters, an optional -AccessToken with a +// Connect-MgGraph fallback, a ShouldProcess gate on mutating calls, and the Kiota client's +// property/indexer chain for the actual request. +public static class CmdletEmitter +{ + // Repeated verbatim in every emitted cmdlet. EmitSharedAuth provides the two helpers this + // block depends on: IsParameterBound and StaticBearerTokenAuthenticationProvider. + private const string AuthBlock = """ + + // ── Choose HttpClient + auth provider ───────────────────────────── + HttpClient httpClient; + IAuthenticationProvider authProvider; + + if (this.IsParameterBound(nameof(AccessToken))) + { + httpClient = new HttpClient(); + authProvider = new StaticBearerTokenAuthenticationProvider(AccessToken!); + } + else + { + WriteVerbose("[MgPoC] No -AccessToken supplied, using the active Connect-MgGraph session."); + try + { + httpClient = HttpHelpers.GetGraphHttpClient(); + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException( + "No active Graph session. Run Connect-MgGraph first, or supply -AccessToken.", ex), + "NoGraphSession", + ErrorCategory.AuthenticationError, + null)); + return; + } + authProvider = new AnonymousAuthenticationProvider(); + } + + var requestAdapter = new HttpClientRequestAdapter(authProvider, httpClient: httpClient); + var client = new ApiClient(requestAdapter); + """; + + private static string PathParams(CmdletNaming naming) => + string.Join("\n", naming.PathParamNames.Select((name, i) => $$""" + [Parameter(Mandatory = true, Position = {{i}})] + public string {{name}} { get; set; } = string.Empty; + """)); + + private static string TargetId(CmdletNaming naming) => + naming.PathParamNames.Count > 0 ? naming.PathParamNames[^1] : "null"; + + // Header parameters declared in the spec (most commonly an "If-Match" ETag on PATCH/DELETE) + // become real cmdlet parameters. Graph sometimes requires them even when the spec marks + // them optional; Planner's PATCH/DELETE is the known example. Dropping them would make + // those endpoints impossible to call. + private static string HeaderParamDecls(CmdletNaming naming) => HeaderParamDeclsFor(naming.HeaderParams, parameterSetName: null); + + private static string HeaderParamDeclsFor(IReadOnlyList headers, string? parameterSetName) + { + var setAttr = parameterSetName is null ? "" : $", ParameterSetName = \"{parameterSetName}\""; + return string.Join("", headers.Select(h => $$""" + + + [Parameter(Mandatory = false{{setAttr}}, + HelpMessage = "Sets the '{{h.RawName}}' request header (for example an ETag for optimistic concurrency; some Graph APIs require it even where the spec marks it optional).")] + public string? {{h.PsName}} { get; set; } + """)); + } + + private static string HeaderBindings(CmdletNaming naming) => HeaderBindingsFor(naming.HeaderParams, extraIndent: ""); + + // extraIndent shifts the lines 4 spaces deeper for call sites inside nested if/else blocks. + private static string HeaderBindingsFor(IReadOnlyList headers, string extraIndent) => + string.Join("", headers.Select(h => $$""" + + + {{extraIndent}}if (this.IsParameterBound(nameof({{h.PsName}}))) + {{extraIndent}} requestConfiguration.Headers.Add("{{h.RawName}}", {{h.PsName}}!); + """)); + + // Splits a paired list/item GET's header parameters into: declared on both operations (bind + // regardless of which parameter set is active), list-only, and get-only. + private static (IReadOnlyList Shared, IReadOnlyList ListOnly, IReadOnlyList GetOnly) PartitionHeaderParams( + CmdletNaming listNaming, CmdletNaming itemNaming) + { + var listNames = listNaming.HeaderParams.Select(h => h.RawName).ToHashSet(StringComparer.OrdinalIgnoreCase); + var itemNames = itemNaming.HeaderParams.Select(h => h.RawName).ToHashSet(StringComparer.OrdinalIgnoreCase); + var shared = listNaming.HeaderParams.Where(h => itemNames.Contains(h.RawName)).ToList(); + var listOnly = listNaming.HeaderParams.Where(h => !itemNames.Contains(h.RawName)).ToList(); + var getOnly = itemNaming.HeaderParams.Where(h => !listNames.Contains(h.RawName)).ToList(); + return (shared, listOnly, getOnly); + } + + // Every emitted cmdlet accepts -AccessToken as an alternative to an active Connect-MgGraph + // session. See AuthBlock. + private static string AccessTokenParamDecl() => """ + [Parameter(Mandatory = false, + HelpMessage = "Bearer access token. Omit if you have already run Connect-MgGraph.")] + public string? AccessToken { get; set; } +"""; + + // The shared try/catch tail around every Graph call. Only the ErrorRecord's target object + // varies, and sometimes the nesting depth (EmitUpdate's re-fetch sits one block deeper). + private static string CatchBlock(string targetIdExpr, string extraIndent = "") => $$""" + {{extraIndent}}catch (Exception ex) + {{extraIndent}}{ + {{extraIndent}}ThrowTerminatingError(new ErrorRecord(ex, "GraphRequestFailed", ErrorCategory.InvalidOperation, {{targetIdExpr}})); + {{extraIndent}}return; + {{extraIndent}}} +"""; + + // The -Headers dictionary, matching the published SDK's parameter of the same name. It + // lets a caller set any header, not just the ones the spec declares. + private static string GenericHeadersParamDecl() => $$""" + + + [Parameter(Mandatory = false, + HelpMessage = "Additional HTTP request headers to send, keyed by header name.")] + public System.Collections.IDictionary? Headers { get; set; } + """; + + private static string GenericHeadersBinding(string extraIndent = "") => $$""" + + + {{extraIndent}}if (this.IsParameterBound(nameof(Headers))) + {{extraIndent}}{ + {{extraIndent}} foreach (System.Collections.DictionaryEntry entry in Headers!) + {{extraIndent}} requestConfiguration.Headers.Add(entry.Key.ToString()!, entry.Value?.ToString() ?? string.Empty); + {{extraIndent}}} + """; + + // Post/Patch/DeleteAsync always take a requestConfiguration lambda, because -Headers exists + // on every cmdlet. Reuses the same binding fragments the GET emitters use, at this call + // site's deeper indent, so there is one binding implementation instead of three. + private static string EmitCallWithOptionalHeaders(CmdletNaming naming, string method, string? bodyArg) + { + var call = $"client.{naming.BuilderExpression}.{method}("; + var args = bodyArg is null ? "" : bodyArg + ", "; + var bindings = HeaderBindingsFor(naming.HeaderParams, extraIndent: " ") + GenericHeadersBinding(" "); + return $"{call}{args}requestConfiguration =>\n {{{bindings}\n }})"; + } + + public static string EmitItemGet(CmdletNaming naming, EmitContext ctx, string entityType) + { + ArgumentNullException.ThrowIfNull(naming); + ArgumentNullException.ThrowIfNull(ctx); + return $$""" +#nullable enable + +using System; +using System.Management.Automation; +using System.Net.Http; +using Microsoft.Graph.PowerShell.Authentication.Helpers; +using {{ctx.ClientNamespace}}; +using {{ctx.ModelsNamespace}}; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; + +namespace {{ctx.CmdletNamespace}} +{ + [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{naming.Noun}}")] + [OutputType(typeof({{entityType}}))] + public class {{naming.ClassName}} : PSCmdlet + { +{{PathParams(naming)}} + +{{AccessTokenParamDecl()}} + + [Parameter(Mandatory = false)] + [Alias("Select")] + public string[]? Property { get; set; } + + [Parameter(Mandatory = false)] + [Alias("Expand")] + public string[]? ExpandProperty { get; set; } +{{HeaderParamDecls(naming)}} +{{GenericHeadersParamDecl()}} + + protected override void ProcessRecord() + { +{{AuthBlock}} + + {{entityType}} result; + try + { + result = client.{{naming.BuilderExpression}}.GetAsync(requestConfiguration => + { + if (this.IsParameterBound(nameof(Property))) + requestConfiguration.QueryParameters.Select = Property; + + if (this.IsParameterBound(nameof(ExpandProperty))) + requestConfiguration.QueryParameters.Expand = ExpandProperty; +{{HeaderBindings(naming)}} +{{GenericHeadersBinding()}} + }).GetAwaiter().GetResult(); + } +{{CatchBlock(TargetId(naming))}} + + WriteObject(result); + } + } +} + +"""; + } + + // Collection GETs do not all support the same OData query options (GET /users has no $skip, + // for example), so the emitted parameters follow what the operation declares, not a fixed + // set. Primary names match the published SDK (Property, ExpandProperty, Sort); the + // wrapper's original names stay as aliases so -Select/-Expand/-OrderBy keep working. + // + // ParamDecl takes the owning parameter-set name (null when the cmdlet has no sets) so the + // dispatcher can derive its "List"-tagged declarations from this same table instead of + // keeping a second copy that would drift. + private static string ParamSetDecl(string? parameterSetName, string propertyDecl, string? alias = null) + { + var setAttr = parameterSetName is null ? "" : $", ParameterSetName = \"{parameterSetName}\""; + var aliasLine = alias is null ? "" : $"\n [Alias(\"{alias}\")]"; + return $" [Parameter(Mandatory = false{setAttr})]{aliasLine}\n {propertyDecl}"; + } + + private static readonly (string ODataName, string PsName, Func ParamDecl, string Binding)[] CollectionQueryOptions = + [ + ("$filter", "Filter", ps => ParamSetDecl(ps, "public string? Filter { get; set; }"), + " if (this.IsParameterBound(nameof(Filter)))\n requestConfiguration.QueryParameters.Filter = Filter;"), + ("$select", "Property", ps => ParamSetDecl(ps, "public string[]? Property { get; set; }", alias: "Select"), + " if (this.IsParameterBound(nameof(Property)))\n requestConfiguration.QueryParameters.Select = Property;"), + ("$expand", "ExpandProperty", ps => ParamSetDecl(ps, "public string[]? ExpandProperty { get; set; }", alias: "Expand"), + " if (this.IsParameterBound(nameof(ExpandProperty)))\n requestConfiguration.QueryParameters.Expand = ExpandProperty;"), + ("$orderby", "Sort", ps => ParamSetDecl(ps, "public string[]? Sort { get; set; }", alias: "OrderBy"), + " if (this.IsParameterBound(nameof(Sort)))\n requestConfiguration.QueryParameters.Orderby = Sort;"), + ("$search", "Search", ps => ParamSetDecl(ps, "public string? Search { get; set; }"), + " if (this.IsParameterBound(nameof(Search)))\n requestConfiguration.QueryParameters.Search = Search;"), + ("$top", "Top", ps => ParamSetDecl(ps, "public int Top { get; set; }"), + " if (this.IsParameterBound(nameof(Top)))\n requestConfiguration.QueryParameters.Top = Top;"), + ("$skip", "Skip", ps => ParamSetDecl(ps, "public int Skip { get; set; }"), + " if (this.IsParameterBound(nameof(Skip)))\n requestConfiguration.QueryParameters.Skip = Skip;"), + ("$count", "Count", ps => ParamSetDecl(ps, "public SwitchParameter Count { get; set; }"), + " if (Count.IsPresent)\n requestConfiguration.QueryParameters.Count = true;"), + ]; + + public static string EmitListGet(CmdletNaming naming, EmitContext ctx, string entityType, string collectionResponseType, IReadOnlySet queryParamNames) + { + ArgumentNullException.ThrowIfNull(naming); + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(queryParamNames); + var applicable = CollectionQueryOptions.Where(o => queryParamNames.Contains(o.ODataName)).ToList(); + var paramDecls = string.Join("\n\n", applicable.Select(o => o.ParamDecl(null))); + var bindings = string.Join("\n\n", applicable.Select(o => o.Binding)); + + return $$""" +#nullable enable + +using System; +using System.Management.Automation; +using System.Net.Http; +using Microsoft.Graph.PowerShell.Authentication.Helpers; +using {{ctx.ClientNamespace}}; +using {{ctx.ModelsNamespace}}; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; + +namespace {{ctx.CmdletNamespace}} +{ + [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{naming.Noun}}")] + [OutputType(typeof({{entityType}}))] + public class {{naming.ClassName}} : PSCmdlet + { +{{PathParams(naming)}} + +{{AccessTokenParamDecl()}} + +{{paramDecls}} +{{HeaderParamDecls(naming)}} +{{GenericHeadersParamDecl()}} + + protected override void ProcessRecord() + { +{{AuthBlock}} + + {{collectionResponseType}} result; + try + { + result = client.{{naming.BuilderExpression}}.GetAsync(requestConfiguration => + { +{{bindings}} +{{HeaderBindings(naming)}} +{{GenericHeadersBinding()}} + }).GetAwaiter().GetResult(); + } +{{CatchBlock(TargetId(naming))}} + + WriteObject(result.Value, enumerateCollection: true); + } + } +} + +"""; + } + + // The dispatcher's list-only parameter declarations: CollectionQueryOptions minus + // $select/$expand, which are shared with the "Get" set and declared once at class level. + // Declarations only; binding happens in the internal list cmdlet the dispatcher calls. + private static IEnumerable<(string ODataName, string ParamDecl)> ListOnlyQueryOptionsForMerge() => + CollectionQueryOptions + .Where(o => o.ODataName is not ("$select" or "$expand")) + .Select(o => (o.ODataName, o.ParamDecl("List"))); + + // Shared path params (-UserId on a nested list) carry no ParameterSetName, which PowerShell + // treats as "all sets". The trailing item id (-MessageId) belongs to the "Get" set only and + // continues the Position sequence where the shared ones left off. + private static string PairedPathParams(IReadOnlyList sharedNames, IReadOnlyList getOnlyNames) + { + var parts = new List(); + var sharedDecls = string.Join("\n", sharedNames.Select((name, i) => $$""" + [Parameter(Mandatory = true, Position = {{i}})] + public string {{name}} { get; set; } = string.Empty; + """)); + if (sharedDecls.Length > 0) + parts.Add(sharedDecls); + + var getOnlyDecls = string.Join("\n", getOnlyNames.Select((name, i) => $$""" + [Parameter(Mandatory = true, ParameterSetName = "Get", Position = {{sharedNames.Count + i}})] + public string {{name}} { get; set; } = string.Empty; + """)); + if (getOnlyDecls.Length > 0) + parts.Add(getOnlyDecls); + + return string.Join("\n", parts); + } + + // The thin public cmdlet for a paired list/item GET. It presents the merged Get-MgX surface + // the published SDK exposes ("List" as the default set, "Get" for item lookups) but makes no + // HTTP call itself. Per the design spec's parameter-set decision, the real work stays in the + // two internal cmdlets; ProcessRecord only picks one and forwards the bound parameters. The + // forward goes through InvokeCommand.InvokeScript on the current runspace, so the nested + // call shares the caller's session, including an active Connect-MgGraph. + public static string EmitGetDispatcher(CmdletNaming listNaming, CmdletNaming itemNaming, + CmdletNaming internalListNaming, CmdletNaming internalItemNaming, EmitContext ctx, + string entityType, string collectionResponseType, IReadOnlySet queryParamNames) + { + ArgumentNullException.ThrowIfNull(listNaming); + ArgumentNullException.ThrowIfNull(itemNaming); + ArgumentNullException.ThrowIfNull(internalListNaming); + ArgumentNullException.ThrowIfNull(internalItemNaming); + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(queryParamNames); + + var sharedPathParams = listNaming.PathParamNames; + var getOnlyPathParams = itemNaming.PathParamNames.Skip(sharedPathParams.Count).ToList(); + + var applicable = ListOnlyQueryOptionsForMerge().Where(o => queryParamNames.Contains(o.ODataName)).ToList(); + var listOnlyParamDecls = string.Join("\n\n", applicable.Select(o => o.ParamDecl)); + + var (sharedHeaders, listOnlyHeaders, getOnlyHeaders) = PartitionHeaderParams(listNaming, itemNaming); + + var internalListCmdletName = $"{internalListNaming.VerbName}-{internalListNaming.Noun}"; + var internalGetCmdletName = $"{internalItemNaming.VerbName}-{internalItemNaming.Noun}"; + + return $$""" +#nullable enable + +using System; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using {{ctx.ModelsNamespace}}; + +namespace {{ctx.CmdletNamespace}} +{ + [Cmdlet(VerbsCommon.Get, "{{listNaming.Noun}}", DefaultParameterSetName = "List")] + [OutputType(typeof({{collectionResponseType}}), ParameterSetName = new[] { "List" })] + [OutputType(typeof({{entityType}}), ParameterSetName = new[] { "Get" })] + public class {{listNaming.ClassName}} : PSCmdlet + { +{{PairedPathParams(sharedPathParams, getOnlyPathParams)}} + +{{AccessTokenParamDecl()}} + + [Parameter(Mandatory = false)] + [Alias("Select")] + public string[]? Property { get; set; } + + [Parameter(Mandatory = false)] + [Alias("Expand")] + public string[]? ExpandProperty { get; set; } + +{{listOnlyParamDecls}} +{{HeaderParamDeclsFor(sharedHeaders, parameterSetName: null)}} +{{HeaderParamDeclsFor(listOnlyHeaders, parameterSetName: "List")}} +{{HeaderParamDeclsFor(getOnlyHeaders, parameterSetName: "Get")}} +{{GenericHeadersParamDecl()}} + + // Delegates to {{internalGetCmdletName}} or {{internalListCmdletName}}, the two cmdlets + // that actually call Graph. + protected override void ProcessRecord() + { + var internalCmdletName = ParameterSetName == "Get" ? "{{internalGetCmdletName}}" : "{{internalListCmdletName}}"; + try + { + InvokeCommand.InvokeScript( + "param($BoundParameters, $CmdletName) & $CmdletName @BoundParameters", + false, + PipelineResultTypes.Output | PipelineResultTypes.Error, + null, + MyInvocation.BoundParameters, internalCmdletName); + } +{{CatchBlock(TargetId(itemNaming))}} + } + } +} + +"""; + } + + public static string EmitNew(CmdletNaming naming, EmitContext ctx, string entityType, IReadOnlyList properties, bool hasPasswordProfile) + { + ArgumentNullException.ThrowIfNull(naming); + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(properties); + return $$""" +#nullable enable + +using System; +using System.Linq; +using System.Management.Automation; +using System.Net.Http; +using Microsoft.Graph.PowerShell.Authentication.Helpers; +using {{ctx.ClientNamespace}}; +using {{ctx.ModelsNamespace}}; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; + +namespace {{ctx.CmdletNamespace}} +{ + [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{naming.Noun}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] + [OutputType(typeof({{entityType}}))] + public class {{naming.ClassName}} : PSCmdlet + { +{{PathParams(naming)}} +{{EmitPropertyParameters(properties)}} +{{(hasPasswordProfile ? EmitPasswordProfileParameters() : "")}} +{{HeaderParamDecls(naming)}} +{{GenericHeadersParamDecl()}} + +{{AccessTokenParamDecl()}} + + protected override void ProcessRecord() + { + if (!ShouldProcess({{TargetId(naming)}}, "{{naming.VerbName}}")) + return; + + var body = new {{entityType}}(); +{{EmitPropertyAssignments(properties)}} +{{(hasPasswordProfile ? EmitPasswordProfileAssignment() : "")}} +{{AuthBlock}} + + {{entityType}}? result; + try + { + result = {{EmitCallWithOptionalHeaders(naming, "PostAsync", "body")}}.GetAwaiter().GetResult(); + } +{{CatchBlock("body")}} + + WriteObject(result); + } + } +} + +"""; + } + + public static string EmitUpdate(CmdletNaming naming, EmitContext ctx, string entityType, IReadOnlyList properties, bool hasPasswordProfile) + { + ArgumentNullException.ThrowIfNull(naming); + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(properties); + return $$""" +#nullable enable + +using System; +using System.Linq; +using System.Management.Automation; +using System.Net.Http; +using Microsoft.Graph.PowerShell.Authentication.Helpers; +using {{ctx.ClientNamespace}}; +using {{ctx.ModelsNamespace}}; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; + +namespace {{ctx.CmdletNamespace}} +{ + [Cmdlet(VerbsData.{{naming.VerbName}}, "{{naming.Noun}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] + [OutputType(typeof({{entityType}}))] + public class {{naming.ClassName}} : PSCmdlet + { +{{PathParams(naming)}} +{{EmitPropertyParameters(properties)}} +{{(hasPasswordProfile ? EmitPasswordProfileParameters() : "")}} +{{HeaderParamDecls(naming)}} +{{GenericHeadersParamDecl()}} + +{{AccessTokenParamDecl()}} + + protected override void ProcessRecord() + { + if (!ShouldProcess({{TargetId(naming)}}, "{{naming.VerbName}}")) + return; + + var body = new {{entityType}}(); +{{EmitPropertyAssignments(properties)}} +{{(hasPasswordProfile ? EmitPasswordProfileAssignment() : "")}} +{{AuthBlock}} + + {{entityType}}? result; + try + { + result = {{EmitCallWithOptionalHeaders(naming, "PatchAsync", "body")}}.GetAwaiter().GetResult(); + } +{{CatchBlock(TargetId(naming))}} + + // Graph often answers a successful PATCH with 204 and no body (seen live on + // schemaExtension update). Re-fetch so the cmdlet returns the updated resource + // instead of nothing. + if (result is null) + { + WriteVerbose("[MgPoC] PATCH succeeded with no response body, re-fetching the updated resource."); + try + { + result = client.{{naming.BuilderExpression}}.GetAsync().GetAwaiter().GetResult(); + } +{{CatchBlock(TargetId(naming), " ")}} + } + + WriteObject(result); + } + } +} + +"""; + } + + public static string EmitRemove(CmdletNaming naming, EmitContext ctx) + { + ArgumentNullException.ThrowIfNull(naming); + ArgumentNullException.ThrowIfNull(ctx); + return $$""" +#nullable enable + +using System; +using System.Management.Automation; +using System.Net.Http; +using Microsoft.Graph.PowerShell.Authentication.Helpers; +using {{ctx.ClientNamespace}}; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; + +namespace {{ctx.CmdletNamespace}} +{ + [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{naming.Noun}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public class {{naming.ClassName}} : PSCmdlet + { +{{PathParams(naming)}} +{{HeaderParamDecls(naming)}} +{{GenericHeadersParamDecl()}} + +{{AccessTokenParamDecl()}} + + protected override void ProcessRecord() + { + if (!ShouldProcess({{TargetId(naming)}}, "{{naming.VerbName}}")) + return; +{{AuthBlock}} + + // DeleteAsync returns a plain Task: a standard delete response has no body. + try + { + {{EmitCallWithOptionalHeaders(naming, "DeleteAsync", null)}} + .GetAwaiter().GetResult(); + } +{{CatchBlock(TargetId(naming))}} + } + } +} + +"""; + } + + // Written once per module. Every cmdlet file relies on same-namespace visibility for these + // helpers instead of carrying its own copy. + public static string EmitSharedAuth(EmitContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + return $$""" +#nullable enable + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Abstractions.Authentication; + +namespace {{ctx.CmdletNamespace}} +{ + internal static class CmdletExtensions + { + public static bool IsParameterBound(this PSCmdlet cmdlet, string parameterName) + => cmdlet.MyInvocation?.BoundParameters.ContainsKey(parameterName) ?? false; + } + + // Minimal IAuthenticationProvider for the -AccessToken path: just stamps the bearer header + // Kiota's own request-adapter pipeline expects, no token acquisition/refresh. + internal sealed class StaticBearerTokenAuthenticationProvider : IAuthenticationProvider + { + private readonly string _token; + + public StaticBearerTokenAuthenticationProvider(string token) + { + _token = token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? token.Substring(7) + : token; + } + + public Task AuthenticateRequestAsync(RequestInformation request, Dictionary? additionalAuthenticationContext = null, CancellationToken cancellationToken = default) + { + request.Headers.TryAdd("Authorization", $"Bearer {_token}"); + return Task.CompletedTask; + } + } +} +"""; + } + + private static string EmitPropertyParameters(IReadOnlyList properties) => + string.Join("\n", properties.Select(p => $$""" + + [Parameter(Mandatory = false)] + public {{p.PsTypeName}}? {{p.PascalName}} { get; set; } + """)); + + private static string EmitPropertyAssignments(IReadOnlyList properties) => + string.Join("\n", properties.Select(p => $$""" + + if (this.IsParameterBound(nameof({{p.PascalName}}))) + body.{{p.PascalName}} = {{(p.IsArray ? $"{p.PascalName}!.ToList()" : p.PascalName)}}; + """)); + + private static string EmitPasswordProfileParameters() => """ + + [Parameter(Mandatory = false, + HelpMessage = "Required by Graph to create a user. Ignored if the resource has no passwordProfile.")] + public string? Password { get; set; } + + [Parameter(Mandatory = false)] + public bool? ForceChangePasswordNextSignIn { get; set; } + """; + + private static string EmitPasswordProfileAssignment() => """ + + if (this.IsParameterBound(nameof(Password)) || this.IsParameterBound(nameof(ForceChangePasswordNextSignIn))) + { + body.PasswordProfile = new PasswordProfile + { + Password = Password, + ForceChangePasswordNextSignIn = ForceChangePasswordNextSignIn ?? true, + }; + } + """; +} diff --git a/src/Kiota.Builder/PowerShellWrapper/CmdletNaming.cs b/src/Kiota.Builder/PowerShellWrapper/CmdletNaming.cs new file mode 100644 index 0000000000..b0b5e19389 --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/CmdletNaming.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Kiota.Builder.Extensions; + +namespace Kiota.Builder.PowerShellWrapper; + +public sealed record HeaderParam(string RawName, string PsName); + +public sealed record CmdletNaming( + string VerbsClass, + string VerbName, + string Noun, + string ClassName, + IReadOnlyList PathParamNames, + string BuilderExpression, + IReadOnlyList HeaderParams); + +public static class Naming +{ + // GET->Get, POST->New, PATCH->Update, PUT->Set, DELETE->Remove (design spec section 7). + // The attribute class is tracked too because it differs per verb: Update lives in + // VerbsData, the rest in VerbsCommon. + private static readonly Dictionary VerbMap = new(StringComparer.OrdinalIgnoreCase) + { + ["get"] = ("VerbsCommon", "Get"), + ["post"] = ("VerbsCommon", "New"), + ["patch"] = ("VerbsData", "Update"), + ["put"] = ("VerbsCommon", "Set"), + ["delete"] = ("VerbsCommon", "Remove"), + }; + + public static CmdletNaming Resolve(OperationInfo operation) + { + ArgumentNullException.ThrowIfNull(operation); + if (!VerbMap.TryGetValue(operation.HttpMethod, out var verb)) + throw new NotSupportedException($"No cmdlet verb mapping for HTTP method '{operation.HttpMethod}'."); + + // The noun comes from the URL path, not the operationId. OperationIds keep whatever + // plurality the spec author chose, while the published SDK names follow the path: + // GET /users/{id}/messages is Get-MgUserMessage. The few hand-tuned exceptions the + // published SDK carries are mirrored as data in NamingOverrides, never as code here. + var noun = "Mg" + NamingOverrides.ApplyNounOverrides(operation.HttpMethod, operation.Path, BuildNounFromPath(operation.Path)); + + // A list GET (/users/{id}/messages) and its item GET (/users/{id}/messages/{message-id}) + // get the same noun on purpose. PowerShellWrapperGenerationService pairs them into one + // public Get-MgX dispatcher cmdlet; the two real implementations get suffixed names via + // WithSuffix below. + var className = $"{verb.VerbName}{noun}Command"; + + var pathParamNames = ExtractPathParamNames(operation.Path); + var builderExpression = BuildBuilderExpression(operation.Path, pathParamNames); + var headerParams = (operation.HeaderParams ?? []) + .Select(raw => new HeaderParam(raw, raw.ToPascalCase('-'))) + .ToList(); + + return new CmdletNaming(verb.VerbsClass, verb.VerbName, noun, className, pathParamNames, builderExpression, headerParams); + } + + // Names one of the two internal cmdlets behind a paired GET dispatcher, e.g. + // Get-MgUserMessage_List. The public dispatcher keeps the bare noun. + public static CmdletNaming WithSuffix(CmdletNaming naming, string suffix) + { + ArgumentNullException.ThrowIfNull(naming); + return naming with + { + Noun = naming.Noun + suffix, + ClassName = $"{naming.VerbName}{naming.Noun}{suffix}Command", + }; + } + + // Pascal-cases and singularizes every fixed path segment, then joins them. Two kinds of + // repetition are dropped, because the published SDK drops them: + // /sites/{id}/sites -> Site (not SiteSite) + // /domains/{id}/domainNameReferences -> DomainNameReference (the shared word "Domain" + // appears once, matching Get-MgDomainNameReference) + // An OData cast segment like graph.user becomes AsUser, matching Get-MgGroupOwnerAsUser. + // Cast type names are already singular, so they skip the singularizer. + private static string BuildNounFromPath(string path) + { + var parts = new List(); + foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment.StartsWith('{') && segment.EndsWith('}')) + continue; + + var castType = segment.StartsWith("microsoft.graph.", StringComparison.OrdinalIgnoreCase) + ? segment["microsoft.graph.".Length..] + : segment.StartsWith("graph.", StringComparison.OrdinalIgnoreCase) + ? segment["graph.".Length..] + : null; + if (castType is not null) + { + parts.Add("As" + castType.ToFirstCharacterUpperCase()); + continue; + } + + var part = Singularizer.SingularizeSegment(segment.ToFirstCharacterUpperCase()); + if (parts.Count > 0) + { + var previous = parts[^1]; + if (string.Equals(previous, part, StringComparison.Ordinal)) + continue; + var boundaryWord = Singularizer.TrailingWord(previous); + if (string.Equals(part, boundaryWord, StringComparison.Ordinal)) + continue; + if (part.StartsWith(boundaryWord, StringComparison.Ordinal) && part.Length > boundaryWord.Length) + part = part[boundaryWord.Length..]; + } + parts.Add(part); + } + return string.Concat(parts); + } + + private static List ExtractPathParamNames(string path) + { + var names = new List(); + foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment.StartsWith('{') && segment.EndsWith('}')) + names.Add(segment[1..^1].ToPascalCase('-')); + } + return names; + } + + // The Kiota-generated ApiClient exposes a property per fixed path segment and an indexer + // per path parameter, e.g. client.Users[UserId].Messages[MessageId]. + private static string BuildBuilderExpression(string path, List pathParamNames) + { + var expression = new System.Text.StringBuilder(); + var paramIndex = 0; + var first = true; + + foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment.StartsWith('{') && segment.EndsWith('}')) + { + expression.Append('[').Append(pathParamNames[paramIndex++]).Append(']'); + } + else + { + if (!first) + expression.Append('.'); + expression.Append(segment.ToFirstCharacterUpperCase()); + } + first = false; + } + + return expression.ToString(); + } +} diff --git a/src/Kiota.Builder/PowerShellWrapper/EmitContext.cs b/src/Kiota.Builder/PowerShellWrapper/EmitContext.cs new file mode 100644 index 0000000000..a6a3bacfed --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/EmitContext.cs @@ -0,0 +1,9 @@ +namespace Kiota.Builder.PowerShellWrapper; + +// The per-module values CmdletEmitter's templates need, so the emitter stays module-agnostic. +// ClientNamespace is whatever --namespace-name the module was generated with, for example +// "Microsoft.Graph.PowerShell.Mail.Client". +public sealed record EmitContext(string ClientNamespace, string CmdletNamespace = "MgPoC") +{ + public string ModelsNamespace => $"{ClientNamespace}.Models"; +} diff --git a/src/Kiota.Builder/PowerShellWrapper/NamingOverrides.cs b/src/Kiota.Builder/PowerShellWrapper/NamingOverrides.cs new file mode 100644 index 0000000000..b07f546890 --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/NamingOverrides.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Kiota.Builder.PowerShellWrapper; + +// Hand-tuned naming exceptions, kept as data with a cited source on every entry. +// +// The published Microsoft.Graph names are mostly algorithmic, but a few come from +// hand-written AutoRest directives in the msgraph-sdk-powershell module configs. Matching +// the published names 100% means mirroring those directives here. +// +// Keep this list short. Add an entry only when the published name cannot come out of the +// naming rules, and cite the directive that created it. +public static partial class NamingOverrides +{ + private enum OverrideKind + { + SuppressOperation, + ReplaceNoun, + StripNounPrefix, + } + + private sealed record Entry(OverrideKind Kind, string? Method, string PathPrefix, bool ExactPath, string? Value, string Reason); + + private static readonly List Entries = + [ + // The SDK ships no Update cmdlet for /users/{id}/calendar. Its pipeline removes the + // operation outright, in src/Calendar/Calendar.md: remove-path-by-operation + // user_UpdateCalendar. The wrapper must not invent a cmdlet the SDK chose to drop. + new(OverrideKind.SuppressOperation, "PATCH", "/users/{}/calendar", ExactPath: true, Value: null, + Reason: "Calendar.md remove-path-by-operation user_UpdateCalendar"), + + // GET /users/{id}/calendar ships as Get-MgUserDefaultCalendar, renamed in + // src/Calendar/Calendar.md: "^(User)(Calendar)$" -> "$1Default$2". + new(OverrideKind.ReplaceNoun, "GET", "/users/{}/calendar", ExactPath: true, Value: "UserDefaultCalendar", + Reason: "Calendar.md directive renames UserCalendar to UserDefaultCalendar"), + + // Every noun under /solutions/ drops the "Solution" prefix: Get-MgBookingBusiness, + // not Get-MgSolutionBookingBusiness. From src/Bookings/Bookings.md: + // "^Solution(.*)$" -> "$1", which applies to the whole module. + new(OverrideKind.StripNounPrefix, Method: null, "/solutions/", ExactPath: false, Value: "Solution", + Reason: "Bookings.md directive strips the Solution subject prefix module-wide"), + ]; + + [GeneratedRegex(@"\{[^}]*\}")] + private static partial Regex PathParamRegex(); + + // Parameter names are erased before comparing, so "/users/{user-id}/calendar" and + // "/users/{id}/calendar" both match the "/users/{}/calendar" entries above. A spec-side + // parameter rename must not silently disable an override. + private static string NormalizePath(string pathTemplate) => + PathParamRegex().Replace(pathTemplate, "{}").TrimEnd('/').ToLowerInvariant(); + + public static bool IsSuppressed(string httpMethod, string pathTemplate) + { + ArgumentNullException.ThrowIfNull(httpMethod); + ArgumentNullException.ThrowIfNull(pathTemplate); + var path = NormalizePath(pathTemplate); + foreach (var entry in Entries) + { + if (entry.Kind == OverrideKind.SuppressOperation && Matches(entry, httpMethod, path)) + return true; + } + return false; + } + + public static string ApplyNounOverrides(string httpMethod, string pathTemplate, string noun) + { + ArgumentNullException.ThrowIfNull(httpMethod); + ArgumentNullException.ThrowIfNull(pathTemplate); + ArgumentNullException.ThrowIfNull(noun); + var path = NormalizePath(pathTemplate); + foreach (var entry in Entries) + { + if (!Matches(entry, httpMethod, path)) + continue; + switch (entry.Kind) + { + case OverrideKind.ReplaceNoun: + return entry.Value!; + case OverrideKind.StripNounPrefix when noun.StartsWith(entry.Value!, StringComparison.Ordinal) && noun.Length > entry.Value!.Length: + noun = noun[entry.Value!.Length..]; + break; + } + } + return noun; + } + + private static bool Matches(Entry entry, string httpMethod, string normalizedPath) + { + if (entry.Method is not null && !string.Equals(entry.Method, httpMethod, StringComparison.OrdinalIgnoreCase)) + return false; + return entry.ExactPath + ? string.Equals(normalizedPath, entry.PathPrefix, StringComparison.Ordinal) + : normalizedPath.StartsWith(entry.PathPrefix, StringComparison.Ordinal); + } +} diff --git a/src/Kiota.Builder/PowerShellWrapper/OperationInfo.cs b/src/Kiota.Builder/PowerShellWrapper/OperationInfo.cs new file mode 100644 index 0000000000..8160e5037d --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/OperationInfo.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Kiota.Builder.PowerShellWrapper; + +public sealed record OperationInfo( + string HttpMethod, + string Path, + string OperationId, + IReadOnlyList PathParams, + IReadOnlyList QueryParams, + bool HasBody, + // True when the success response wraps results in a "value" array (GET /users) rather + // than returning a single entity (GET /users/{id}). Path-param count alone cannot tell + // these apart for nested resources: a nested list and a nested get-by-id both carry the + // parent's path param. + bool IsCollectionResponse = false, + // Raw OpenAPI header parameter names, for example "If-Match". Graph sometimes requires + // these even where the spec marks them optional (Planner's PATCH/DELETE), so they become + // real cmdlet parameters instead of being dropped. + IReadOnlyList? HeaderParams = null); diff --git a/src/Kiota.Builder/PowerShellWrapper/PowerShellWrapperGenerationService.cs b/src/Kiota.Builder/PowerShellWrapper/PowerShellWrapperGenerationService.cs new file mode 100644 index 0000000000..87b60014a2 --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/PowerShellWrapperGenerationService.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Kiota.Builder.Configuration; +using Kiota.Builder.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; + +namespace Kiota.Builder.PowerShellWrapper; + +// Drives the "PowerShellWrapper" generation language: emits one PowerShell cmdlet class per +// selected OpenAPI operation, straight from the OpenApiDocument that KiotaBuilder already +// loaded and filtered with --include-path/--exclude-path. It bypasses the CodeDOM, refiner, +// and writer pipeline, the same way PluginsGenerationService does for plugin output. +public sealed partial class PowerShellWrapperGenerationService +{ + private readonly OpenApiDocument document; + private readonly GenerationConfiguration config; + private readonly ILogger logger; + + public PowerShellWrapperGenerationService(OpenApiDocument document, GenerationConfiguration configuration, ILogger logger) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(logger); + this.document = document; + config = configuration; + this.logger = logger; + } + + // One GET operation from the first pass, held until we know whether it pairs with a + // list/item partner. CollectionValueSchema is the response's "value" array property when + // the response is a collection, null for a single entity. It is resolved once here so + // later steps never re-walk the schema. + private sealed record GetOperationRecord(CmdletNaming Naming, IOpenApiSchema ResponseSchema, IOpenApiSchema? CollectionValueSchema, IReadOnlyList QueryParams) + { + public bool IsCollection => CollectionValueSchema is not null; + } + + public async Task GenerateAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var ctx = new EmitContext(ClientNamespace: config.ClientNamespaceName); + + Directory.CreateDirectory(config.OutputPath); + foreach (var stale in Directory.GetFiles(config.OutputPath, "*.g.cs")) + File.Delete(stale); + + await File.WriteAllTextAsync(Path.Combine(config.OutputPath, "Shared.g.cs"), CmdletEmitter.EmitSharedAuth(ctx), cancellationToken).ConfigureAwait(false); + LogWroteSharedFile(); + + var written = 0; + var getOperations = new List(); + + foreach (var (pathTemplate, pathItem) in document.Paths) + { + foreach (var (httpMethod, operation) in pathItem.Operations ?? new Dictionary()) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Skip operations the published SDK deliberately does not ship. NamingOverrides + // holds the citation for each one. + if (NamingOverrides.IsSuppressed(httpMethod.Method, pathTemplate)) + { + LogSuppressedOperation(httpMethod.Method, pathTemplate); + continue; + } + + var pathParams = (operation.Parameters ?? []).Where(p => p.In == ParameterLocation.Path).Select(p => p.Name!).ToList(); + var queryParams = (operation.Parameters ?? []).Where(p => p.In == ParameterLocation.Query).Select(p => p.Name!).ToList(); + var headerParams = (operation.Parameters ?? []).Where(p => p.In == ParameterLocation.Header).Select(p => p.Name!).ToList(); + + // Only GET responses need inspecting: the list/item pairing is decided by the + // response shape, and DELETE returns 204 with no body. + var responseSchema = httpMethod == HttpMethod.Get + ? operation.Responses?["2XX"].Content?["application/json"].Schema + : null; + var collectionValueSchema = responseSchema is not null ? FindProperty(responseSchema, "value") : null; + var isCollection = collectionValueSchema is not null; + + var operationId = operation.OperationId + ?? throw new InvalidOperationException($"Operation at '{pathTemplate}' ({httpMethod}) has no operationId."); + var opInfo = new OperationInfo(httpMethod.Method, pathTemplate, operationId, pathParams, queryParams, operation.RequestBody is not null, isCollection, headerParams); + var cmdletNaming = Naming.Resolve(opInfo); + + // GETs are held back: pairing is decided per noun, so it needs every GET first. + if (httpMethod == HttpMethod.Get) + { + getOperations.Add(new GetOperationRecord(cmdletNaming, responseSchema!, collectionValueSchema, queryParams)); + continue; + } + + string source = httpMethod switch + { + _ when httpMethod == HttpMethod.Delete => CmdletEmitter.EmitRemove(cmdletNaming, ctx), + _ when httpMethod == HttpMethod.Post => EmitNewFor(cmdletNaming, ctx, operation), + _ when httpMethod == HttpMethod.Patch => EmitUpdateFor(cmdletNaming, ctx, operation), + _ => throw new NotSupportedException($"No PowerShell wrapper emitter for {httpMethod} {pathTemplate}."), + }; + + written += await WriteCmdletFileAsync(cmdletNaming, source, cancellationToken).ConfigureAwait(false); + } + } + + written += await EmitGetOperationsAsync(getOperations, ctx, cancellationToken).ConfigureAwait(false); + + LogWroteFiles(written + 1, config.OutputPath); + } + + // Pairs a list GET (GET /users/{id}/messages) with its item GET + // (GET /users/{id}/messages/{message-id}) and presents them as one public Get-MgX cmdlet, + // matching the published SDK surface. The real work stays in two separate internal cmdlets + // (the *_List/*_Get classes named by Naming.WithSuffix); the public dispatcher only picks + // which one to invoke. + // + // A pairing is only trusted when it is structurally unambiguous: exactly one collection GET + // and one single-entity GET share the noun, and the item's path is the list's path plus one + // trailing id (Users[UserId].Messages -> Users[UserId].Messages[MessageId]). Everything + // else keeps the standalone shape: singleton navs with no list (GET /users/{id}/calendar), + // list-only endpoints such as delta queries, or an unexpected same-noun collision. + private async Task EmitGetOperationsAsync(List getOperations, EmitContext ctx, CancellationToken cancellationToken) + { + var written = 0; + var consumed = new HashSet(); + + var listsByNoun = getOperations.Where(o => o.IsCollection).ToLookup(o => o.Naming.Noun); + var itemsByNoun = getOperations.Where(o => !o.IsCollection).ToLookup(o => o.Naming.Noun); + + foreach (var listGroup in listsByNoun) + { + if (listGroup.Count() != 1) + continue; + var listOp = listGroup.Single(); + + var itemGroup = itemsByNoun[listGroup.Key]; + if (itemGroup.Count() != 1) + continue; + var itemOp = itemGroup.Single(); + + if (itemOp.Naming.PathParamNames.Count != listOp.Naming.PathParamNames.Count + 1) + continue; + if (!itemOp.Naming.PathParamNames.Take(listOp.Naming.PathParamNames.Count).SequenceEqual(listOp.Naming.PathParamNames)) + continue; + if (!itemOp.Naming.BuilderExpression.StartsWith(listOp.Naming.BuilderExpression + "[", StringComparison.Ordinal)) + continue; + + var listEntityType = ResolveListEntityTypeName(listOp.CollectionValueSchema!, ctx.ModelsNamespace); + var entityType = ResolveEntityTypeName(itemOp.ResponseSchema, ctx.ModelsNamespace); + var collectionResponseType = listEntityType + "CollectionResponse"; + + // The two real implementations: separate, independently documented cmdlets, unchanged + // from (and reusing) the standalone shapes used for unpaired GETs. + var internalListNaming = Naming.WithSuffix(listOp.Naming, "_List"); + var internalItemNaming = Naming.WithSuffix(itemOp.Naming, "_Get"); + var internalListSource = CmdletEmitter.EmitListGet(internalListNaming, ctx, listEntityType, collectionResponseType, listOp.QueryParams.ToHashSet()); + var internalItemSource = CmdletEmitter.EmitItemGet(internalItemNaming, ctx, entityType); + + // The thin public dispatcher on top, presenting the merged Get-MgX surface. + var dispatcherSource = CmdletEmitter.EmitGetDispatcher(listOp.Naming, itemOp.Naming, + internalListNaming, internalItemNaming, ctx, entityType, collectionResponseType, listOp.QueryParams.ToHashSet()); + + written += await WriteCmdletFileAsync(internalListNaming, internalListSource, cancellationToken).ConfigureAwait(false); + written += await WriteCmdletFileAsync(internalItemNaming, internalItemSource, cancellationToken).ConfigureAwait(false); + written += await WriteCmdletFileAsync(listOp.Naming, dispatcherSource, cancellationToken).ConfigureAwait(false); + consumed.Add(listOp); + consumed.Add(itemOp); + } + + foreach (var op in getOperations) + { + if (consumed.Contains(op)) + continue; + + var source = op.IsCollection + ? EmitListGetFor(op.Naming, ctx, op.CollectionValueSchema!, op.QueryParams) + : CmdletEmitter.EmitItemGet(op.Naming, ctx, ResolveEntityTypeName(op.ResponseSchema, ctx.ModelsNamespace)); + + written += await WriteCmdletFileAsync(op.Naming, source, cancellationToken).ConfigureAwait(false); + } + + return written; + } + + private async Task WriteCmdletFileAsync(CmdletNaming naming, string source, CancellationToken cancellationToken) + { + var fileName = naming.ClassName.Replace("Command", "", StringComparison.Ordinal) + ".g.cs"; + await File.WriteAllTextAsync(Path.Combine(config.OutputPath, fileName), source, cancellationToken).ConfigureAwait(false); + LogWroteCmdletFile(fileName, naming.VerbName, naming.Noun); + return 1; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Wrote Shared.g.cs")] + private partial void LogWroteSharedFile(); + [LoggerMessage(Level = LogLevel.Information, Message = "Wrote {FileName} ({Verb}-{Noun})")] + private partial void LogWroteCmdletFile(string fileName, string verb, string noun); + [LoggerMessage(Level = LogLevel.Information, Message = "Wrote {Count} file(s) to {OutputPath}")] + private partial void LogWroteFiles(int count, string outputPath); + [LoggerMessage(Level = LogLevel.Information, Message = "Suppressed {Method} {PathTemplate}: the published SDK ships no cmdlet for it (see NamingOverrides)")] + private partial void LogSuppressedOperation(string method, string pathTemplate); + + private static string EmitListGetFor(CmdletNaming naming, EmitContext ctx, IOpenApiSchema collectionValueSchema, IReadOnlyList queryParams) + { + var entityType = ResolveListEntityTypeName(collectionValueSchema, ctx.ModelsNamespace); + // Appending "CollectionResponse" works for bare names ("User") and qualified ones + // ("Ns.Security.Alert") alike, because Kiota puts the collection model next to the + // item model. + return CmdletEmitter.EmitListGet(naming, ctx, entityType, entityType + "CollectionResponse", queryParams.ToHashSet()); + } + + // collectionValueSchema is the already-resolved "value" array property from + // GetOperationRecord, so nothing is re-walked here. + private static string ResolveListEntityTypeName(IOpenApiSchema collectionValueSchema, string modelsNamespace) + { + var itemSchema = collectionValueSchema.Items + ?? throw new InvalidOperationException("Expected the 'value' array items to be present."); + return SchemaNameToTypeName(ResolveReferenceId(itemSchema), modelsNamespace); + } + + // The old openApiDocs specs declare "value" directly on the schema; the KiotaCompat specs + // compose it through allOf. Look in both places, recursively for nested composition. + private static IOpenApiSchema? FindProperty(IOpenApiSchema schema, string propertyName) + { + if (schema.Properties?.TryGetValue(propertyName, out var direct) == true) + return direct; + + foreach (var branch in schema.AllOf ?? []) + { + if (FindProperty(branch, propertyName) is { } found) + return found; + } + + return null; + } + + private static string EmitNewFor(CmdletNaming naming, EmitContext ctx, OpenApiOperation operation) + { + var bodySchema = operation.RequestBody!.Content!["application/json"].Schema!; + return CmdletEmitter.EmitNew(naming, ctx, ResolveEntityTypeName(bodySchema, ctx.ModelsNamespace), + SchemaProperties.ExtractPrimitiveProperties(bodySchema), SchemaProperties.HasPasswordProfile(bodySchema)); + } + + private static string EmitUpdateFor(CmdletNaming naming, EmitContext ctx, OpenApiOperation operation) + { + var bodySchema = operation.RequestBody!.Content!["application/json"].Schema!; + return CmdletEmitter.EmitUpdate(naming, ctx, ResolveEntityTypeName(bodySchema, ctx.ModelsNamespace), + SchemaProperties.ExtractPrimitiveProperties(bodySchema), SchemaProperties.HasPasswordProfile(bodySchema)); + } + + private static string ResolveEntityTypeName(IOpenApiSchema schema, string modelsNamespace) => + SchemaNameToTypeName(ResolveReferenceId(schema), modelsNamespace); + + private static string ResolveReferenceId(IOpenApiSchema schema) => + schema.GetReferenceId() is string id && !string.IsNullOrEmpty(id) + ? id + : throw new InvalidOperationException("Expected a $ref schema for entity type resolution."); + + private static string SchemaNameToTypeName(string schemaName, string modelsNamespace) + { + var name = schemaName.StartsWith("microsoft.graph.", StringComparison.Ordinal) + ? schemaName["microsoft.graph.".Length..] + : schemaName; + + // Kiota nests each dot segment as a sub-namespace under Models ("security.alert" + // becomes Models.Security.Alert). A using directive does not reach into nested + // namespaces, so multi-segment names are fully qualified; single-segment names, + // the common case, stay bare. + var segments = name.Split('.').Select(static segment => char.ToUpperInvariant(segment[0]) + segment[1..]).ToArray(); + return segments.Length == 1 ? segments[0] : $"{modelsNamespace}.{string.Join('.', segments)}"; + } +} diff --git a/src/Kiota.Builder/PowerShellWrapper/README.md b/src/Kiota.Builder/PowerShellWrapper/README.md new file mode 100644 index 0000000000..42cb8466f1 --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/README.md @@ -0,0 +1,225 @@ +# PowerShell Wrapper Generator + +This folder implements the `PowerShellWrapper` generation language for kiota: it reads a +Microsoft Graph OpenAPI document and emits PowerShell cmdlet classes that call the +Kiota-generated C# request builders. It is the AutoRest replacement path for the Microsoft +Graph PowerShell SDK (see the project design spec for goals and non-goals). + +This README covers the architecture, how to generate and package a module, the naming +algorithm, how quality is measured, and what is deliberately not built yet. The step-by-step +verification runbook lives in the msgraph-sdk-powershell repo at `tools/WrapperRunbook.md`. + +## 1. Two repos, one pipeline + +| Repo | Holds | +|---|---| +| `kiota` (this one) | The generator: naming, emitters, generation service, unit tests, this README | +| `msgraph-sdk-powershell` | The OpenAPI specs (`openApiDocs_KiotaCompat/`), the generated modules (`generated/`), the verification tools (`tools/Compare-WrapperCmdletNames.ps1`, `tools/Update-WrapperModuleManifests.ps1`, `tools/WrapperRunbook.md`), and the naming oracle (`src/Authentication/Authentication/custom/common/MgCommandMetadata.json`) | + +The oracle file is the Method+URI to command-name inventory that ships inside +`Microsoft.Graph.Authentication` and powers `Find-MgGraphCommand`, about 29,000 entries. +Every naming rule in this generator cites it as ground truth. + +## 2. Architecture + +Generation flow, one module at a time: + +``` +kiota generate -l PowerShellWrapper + -> KiotaBuilder loads the OpenAPI doc and applies --include-path/--exclude-path + -> PowerShellWrapperGenerationService walks the filtered operations + - NamingOverrides.IsSuppressed skips operations the published SDK omits + - Naming.Resolve names each operation (verb from HTTP method, noun from path) + - GETs are held back and paired: a list GET and its item GET that share a noun + become one public dispatcher cmdlet plus two internal _List/_Get cmdlets + - CmdletEmitter renders each cmdlet class as C# source + -> one .g.cs file per cmdlet, plus Shared.g.cs, written to the output folder +``` + +File map: + +| File | Role | +|---|---| +| `PowerShellWrapperGenerationService.cs` | Orchestrates a generation run; owns GET pairing and suppression | +| `CmdletNaming.cs` | `Naming.Resolve`: verb map, noun from path, builder expression, path params | +| `Singularizer.cs` | Word-level singularization rules (section 5 below) | +| `NamingOverrides.cs` | The checked-in exception data: renames and suppressions, each with a cited source | +| `CmdletEmitter.cs` | C# source templates for the cmdlet shapes (item GET, list GET, dispatcher, New, Update, Remove, shared auth helpers) | +| `SchemaProperties.cs` | Maps body-schema primitives onto write-cmdlet parameters | +| `OperationInfo.cs`, `EmitContext.cs` | Plain data carriers | + +## 3. Generating a module + +Build the CLI once, then generate against a spec: + +```powershell +dotnet build src\kiota\kiota.csproj -c Debug -f net10.0 + +src\kiota\bin\Debug\net10.0\kiota.exe generate -l PowerShellWrapper ` + -d \openApiDocs_KiotaCompat\v1.0\Mail.yml ` + -o \generated\Mail ` + -c ApiClient -n Microsoft.Graph.PowerShell.Mail.Client ` + --include-path '/users/{user-id}/message[s]#GET,POST' ` + --include-path '/users/{user-id}/messages/{message-id}*#GET,DELETE' +``` + +Things that will bite you if you don't know them: + +- **kiota skips generation when nothing changed.** The `kiota-lock.json` in the output folder + records the spec hash and settings; a matching hash means "no changes detected". To force a + rerun after a generator change, delete `kiota-lock.json` first. Do NOT use `--clean-output`: + it wipes the whole output folder, including the `Module/` project that lives next to the + generated sources. +- **Each module's `kiota-lock.json` is the record of how it was generated** (spec path, + include patterns, namespace). A regeneration script can drive every module from its lock + file alone; see the runbook. +- **Generation writes only `*.g.cs`.** The `Module/` folder (csproj, psd1 manifest) is + maintained separately, which leads to the next section. + +## 4. Packaging: the manifest trap + +Each module's `.psd1` manifest lists `CmdletsToExport` explicitly. PowerShell gives **no +warning** when an export list matches nothing, so a renamed or suppressed cmdlet leaves the +module importing successfully while exporting nothing. This happened in practice when the +naming fix landed. + +After any generation that changes cmdlet names, regenerate the manifests from the generated +sources (msgraph-sdk-powershell repo): + +```powershell +tools\Update-WrapperModuleManifests.ps1 +``` + +It rewrites every manifest's `CmdletsToExport` from the `[Cmdlet(...)]` attributes actually +present in the `.g.cs` files, so packaging cannot drift from the generated surface. + +## 5. Naming algorithm + +The target is byte-identical parity with the published `Microsoft.Graph.*` cmdlet names +(project success criterion: 100% name match per module against Microsoft.Graph 2.37.0+). + +### 5.1 Verb: HTTP method + +| HTTP method | PowerShell verb | +|---|---| +| GET | `Get` | +| POST | `New` | +| PATCH | `Update` | +| PUT | `Set` | +| DELETE | `Remove` | + +### 5.2 Noun: URL path, not operationId + +The noun is built from the URL path template. The operationId is deliberately not used: it +carries whatever plurality and casing the spec author chose, while the published names are a +deterministic function of the path. This also keeps naming and request routing +(`BuildBuilderExpression`) on one source of truth. + +For `GET /identity/conditionalAccess/policies/{conditionalAccessPolicy-id}`: + +1. Drop `{parameter}` segments: `identity`, `conditionalAccess`, `policies`. +2. Pascal-case each segment and singularize it (5.3): `Identity`, `ConditionalAccess`, + `Policy`. +3. Collapse seams (5.4), apply overrides (5.5), prefix `Mg`: + `MgIdentityConditionalAccessPolicy`. +4. Final name: `Get-MgIdentityConditionalAccessPolicy`, identical to the published SDK. + +OData type-cast segments (`graph.user` in the KiotaCompat docs) become `As` + type name, no +singularization (cast type names are already singular): `GET /groups/{id}/owners/{id}/graph.user` +yields `Get-MgGroupOwnerAsUser`, matching the published convention. + +### 5.3 Singularization (`Singularizer.cs`) + +The published SDK's inflector works per camel-case word, not per whole segment. Proof from +shipping names: `termsAndConditions` ships as `Update-MgDeviceManagementTermAndCondition` (both +words singularized) and `onPremisesSynchronization` as +`Get-MgDirectoryOnPremiseSynchronization` (an interior word singularized). So each segment is +split into camel-case words and each word runs through the ordered rules below; first match +wins. Order is part of the algorithm. + +| # | Rule | Example (shipping cmdlet) | +|---|---|---| +| 0 | Version tag `_v` on the segment: singularize the stem, append `V` | `alerts_v2` -> `AlertV2` (`Get-MgSecurityAlertV2`) | +| 1 | Words shorter than 3 chars, or all-uppercase (acronyms), unchanged | `OS` stays `OS` | +| 2 | Irregulars table | `children` -> `Child` (`Get-MgDriveItemChild`), `people` -> `Person` (`Get-MgUserPerson`) | +| 3 | Invariants table: words the SDK never singularizes | `windows` stays (`Get-MgUserSettingWindows`) | +| 4 | `ies` -> `y` | `policies` -> `Policy` | +| 5 | `uses` -> `us` | `statuses` -> `Status` (`Get-MgDeviceManagementDeviceConfigurationUserStatus`) | +| 6 | `es` after `x`, `z`, `ch`, `sh`, `ss`: drop `es` | `bookingBusinesses` -> `BookingBusiness`, `mailboxes` -> `Mailbox` | +| 7 | Ends `ss`, `us`, `is`: unchanged | `conditionalAccess`, `status`, `analysis` stay put | +| 8 | Ends `s`: drop it | `messages` -> `Message`, `plans` -> `Plan`, `settings` -> `Setting` | + +The irregulars and invariants tables grow only from parity-harness evidence (section 6), never +from intuition. + +### 5.4 Seam collapse + +Applied while concatenating the singularized segments: + +- **Adjacent duplicates** are dropped: `/users/{id}/onenote/sectionGroups/{id}/sectionGroups` + yields `MgUserOnenoteSectionGroup...`, matching the published family + (`Get-MgUserOnenoteSectionGroupCount`), not `...SectionGroupSectionGroup...`. +- **Boundary word overlap**: when the previous part's trailing word starts the next part, the + repeated word is dropped once. `/domains/{id}/domainNameReferences` yields + `MgDomainNameReference` (`Get-MgDomainNameReference`), and + `.../managedDevices/{id}/deviceConfigurationStates` yields + `...ManagedDeviceConfigurationState` (`Get-MgUserManagedDeviceConfigurationState`). + +### 5.5 Overrides and suppression (`NamingOverrides.cs`) + +The published names are not 100% algorithmic: a handful come from hand-written AutoRest +directives in the msgraph-sdk-powershell module configs. Those cases are mirrored as checked-in +data, one citation per entry, never as code branches: + +| Entry | Source directive | +|---|---| +| Strip leading `Solution` for `/solutions/**` nouns (`Get-MgBookingBusiness`) | `src/Bookings/Bookings.md`: `^Solution(.*)$ -> $1` | +| `GET /users/{id}/calendar` -> `UserDefaultCalendar` | `src/Calendar/Calendar.md`: `^(User)(Calendar)$ -> $1Default$2` | +| Suppress `PATCH /users/{id}/calendar` (SDK ships no such cmdlet) | `src/Calendar/Calendar.md`: `remove-path-by-operation user_UpdateCalendar` | + +Before adding an entry, prove the published name cannot fall out of the rules, and cite where +the SDK's own pipeline hand-tuned it. Known future entries of the "mirror their artifact" kind: +`Get-MgDeviceManagementIoUpdateStatus` (the published inflector singularized `Ios` to `Io`). + +### 5.6 Deliberate deviations from the published surface + +- The internal `Get-MgX_Get` / `Get-MgX_List` cmdlets are visible alongside each public + dispatcher. The published SDK hides its variants inside parameter sets instead. This is the + design spec's resolved parameter-set decision and belongs in the migration guide. + +## 6. How quality is measured + +Quality here is a number a script prints, not an assertion: + +- **Unit goldens**: `tests/Kiota.Builder.Tests/PowerShellWrapper/NamingTests.cs` pins the rules + to published names taken from the oracle. Run: + `dotnet test tests\Kiota.Builder.Tests\Kiota.Builder.Tests.csproj --filter "FullyQualifiedName~PowerShellWrapper"`. + Every new naming behavior gets its row here first, with the expected value copied from + `Find-MgGraphCommand` or the oracle file, never typed from memory. +- **Parity gate**: `tools/Compare-WrapperCmdletNames.ps1` (msgraph-sdk-powershell repo) + reconstructs each generated cmdlet's URI from its builder expression, joins it to the + oracle, prints per-module parity, and exits 1 on any mismatch. Status 2026-07-24: 66/66 + across the 15 pilot modules. +- **Full-surface dry run**: simulating the algorithm over every v1.0 CRUD operation in the + oracle (about 9,000) scores 68.4% with zero module-specific data; the remainder is + categorized as per-module directive renames (for example `IdentityGovernance...` prefixes + dropped to `EntitlementManagement...`), which become override entries as modules are + onboarded, exactly where AutoRest keeps them today. + +The end-to-end verification runbook (generate, gate, build, import, oracle cross-check, live +tenant) is `tools/WrapperRunbook.md` in the msgraph-sdk-powershell repo. + +## 7. Current status and known gaps + +Honest state as of 2026-07-24, so nobody has to reverse-engineer it from git history: + +| Area | State | +|---|---| +| Name parity | 100% on the 15 pilot modules, gated | +| Auth, query params, body binding | Working, verified against a live tenant | +| Pagination (`-All`, nextLink warning) | Designed in the spec, not yet wired | +| Breadth | 66 cmdlets; the full SDK surface is ~29,000 operations | +| Operation classes | No `$count`, `$ref`, `$value`, delta, OData actions/functions (`Invoke` verb), or cast endpoints generated yet (the cast naming rule exists and is tested) | +| Body binding depth | Top-level primitive properties only; no complex types, enums, or typed formats | +| Self-referencing paths | `/sites/{id}/sites` ships as the hand-renamed `Get-MgSubSite`; not yet onboarded | +| Oracle cast join | The oracle renders cast URIs without the `graph.` prefix; the parity gate needs a cast-aware join before the first cast operation is onboarded | diff --git a/src/Kiota.Builder/PowerShellWrapper/SchemaProperties.cs b/src/Kiota.Builder/PowerShellWrapper/SchemaProperties.cs new file mode 100644 index 0000000000..950be8100a --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/SchemaProperties.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Kiota.Builder.Extensions; +using Microsoft.OpenApi; + +namespace Kiota.Builder.PowerShellWrapper; + +public sealed record CmdletProperty(string OpenApiName, string PascalName, string PsTypeName, bool IsArray); + +// Maps a body schema's top-level primitive properties onto cmdlet parameters. Deliberately +// shallow, per team decision: nested complex properties (assignedLicenses, employeeOrgData, +// and the like) are skipped rather than modeled. Two special cases: "id" is excluded because +// the server assigns it, and passwordProfile is flagged separately via HasPasswordProfile +// because creating a user requires it. +public static class SchemaProperties +{ + public static IReadOnlyList ExtractPrimitiveProperties(IOpenApiSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + void Walk(IOpenApiSchema s) + { + foreach (var inherited in s.AllOf ?? []) + Walk(inherited); + + foreach (var (name, propSchema) in s.Properties ?? new Dictionary()) + { + if (IsProtocolOrServerManagedProperty(name, propSchema) || !seen.Add(name)) + continue; + + if (IsPlainScalar(propSchema)) + { + result.Add(new CmdletProperty(name, name.ToFirstCharacterUpperCase(), MapPsType(propSchema.Type!.Value), IsArray: false)); + } + else if (propSchema.Type == JsonSchemaType.Array && propSchema.Items is { } items && IsPlainScalar(items)) + { + result.Add(new CmdletProperty(name, name.ToFirstCharacterUpperCase(), MapPsType(items.Type!.Value) + "[]", IsArray: true)); + } + } + } + + Walk(schema); + return result; + } + + // passwordProfile is a nested complex type, so ExtractPrimitiveProperties skips it, but + // Graph requires it to create a user. This flag lets the emitter add the two flattened + // parameters (-Password, -ForceChangePasswordNextSignIn) that make New-MgUser usable. + public static bool HasPasswordProfile(IOpenApiSchema schema) + { + ArgumentNullException.ThrowIfNull(schema); + if (schema.Properties?.ContainsKey("passwordProfile") ?? false) + return true; + return schema.AllOf?.Any(HasPasswordProfile) ?? false; + } + + // A "format" on a string (date-time, uuid, byte, ...) means Kiota maps it to a non-string + // CLR type, and an enum-valued string becomes a real enum type. Both are left out rather + // than guessing Kiota's mapping and risking a type mismatch. Schema.Type is a flags enum + // and nullable unions set the Null bit, so it is masked off before comparing. + private static bool IsPlainScalar(IOpenApiSchema schema) => (schema.Type & ~JsonSchemaType.Null) switch + { + JsonSchemaType.String => string.IsNullOrEmpty(schema.Format) && (schema.Enum?.Count ?? 0) == 0, + JsonSchemaType.Boolean or JsonSchemaType.Integer or JsonSchemaType.Number => true, + _ => false, + }; + + private static string MapPsType(JsonSchemaType openApiType) => (openApiType & ~JsonSchemaType.Null) switch + { + JsonSchemaType.String => "string", + JsonSchemaType.Boolean => "bool", + JsonSchemaType.Integer or JsonSchemaType.Number => "int", + _ => "string", + }; + + // Excludes properties a caller cannot or should not set. "id" is server-assigned. + // "@"-prefixed names like "@odata.type" are OData control data that Kiota's serializer + // fills in from the model type, and they are not legal C# identifiers anyway. ReadOnly is + // the general OpenAPI signal for server-managed. Future exclusions of this kind belong here. + private static bool IsProtocolOrServerManagedProperty(string name, IOpenApiSchema propSchema) => + name == "id" || name.StartsWith('@') || propSchema.ReadOnly; +} diff --git a/src/Kiota.Builder/PowerShellWrapper/Singularizer.cs b/src/Kiota.Builder/PowerShellWrapper/Singularizer.cs new file mode 100644 index 0000000000..d82a6201d3 --- /dev/null +++ b/src/Kiota.Builder/PowerShellWrapper/Singularizer.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace Kiota.Builder.PowerShellWrapper; + +// Singularizes path segments so cmdlet nouns match the published Microsoft.Graph names. +// +// This is not a general English inflector. The vocabulary is the finite set of Graph path +// segments, and every rule exists because a shipping cmdlet needs it. The README in this +// folder lists each rule with the cmdlet that proves it. +// +// One behavior matters most: the published SDK singularizes every camel-case word in a +// segment, not just the last one. "termsAndConditions" ships as TermAndCondition and +// "onPremisesSynchronization" as OnPremiseSynchronization. SingularizeSegment does the same: +// it splits a segment into words and runs the rules on each word. +public static partial class Singularizer +{ + // Irregular plurals the SDK singularizes: Get-MgDriveItemChild, Get-MgUserPerson. + private static readonly Dictionary Irregulars = new(StringComparer.Ordinal) + { + ["Children"] = "Child", + ["People"] = "Person", + }; + + // Words that end in "s" but are not plurals. The SDK keeps them as-is: + // /users/{id}/settings/windows ships as Get-MgUserSettingWindows. + private static readonly HashSet Invariants = new(StringComparer.Ordinal) + { + "Windows", + }; + + // Splits Pascal or camel text into words. Handles acronym runs ("OS" in "MacOSDmgApp"), + // trailing digits ("Win32"), and a leading lowercase word. + [GeneratedRegex("[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+")] + private static partial Regex WordRegex(); + + // Segments with a version tag: "alerts_v2" must become "AlertV2" (Get-MgSecurityAlertV2). + // Underscores are not legal in a cmdlet noun anyway. + [GeneratedRegex(@"^(?.+)_[vV](?\d+)$")] + private static partial Regex VersionTagRegex(); + + // Singularizes one already-Pascal-cased path segment, word by word. + public static string SingularizeSegment(string pascalSegment) + { + ArgumentNullException.ThrowIfNull(pascalSegment); + + var versionTag = VersionTagRegex().Match(pascalSegment); + if (versionTag.Success) + return SingularizeSegment(versionTag.Groups["stem"].Value) + "V" + versionTag.Groups["version"].Value; + + var result = new StringBuilder(pascalSegment.Length); + foreach (Match word in WordRegex().Matches(pascalSegment)) + result.Append(SingularizeWord(word.Value)); + return result.ToString(); + } + + // Returns the last camel-case word of a noun part: "Workflow" for "LifecycleWorkflow". + // BuildNounFromPath uses this to spot a word repeated across a segment boundary. + public static string TrailingWord(string pascalText) + { + ArgumentNullException.ThrowIfNull(pascalText); + var matches = WordRegex().Matches(pascalText); + return matches.Count > 0 ? matches[^1].Value : pascalText; + } + + // Ordered rules; first match wins, so the order is part of the algorithm. The README's + // rule table gives the shipping cmdlet behind each rule. + public static string SingularizeWord(string word) + { + ArgumentNullException.ThrowIfNull(word); + if (word.Length < 3) + return word; + if (IsAllUpper(word)) + return word; // acronyms ("OS", "SMS") are never plural forms + if (Irregulars.TryGetValue(word, out var irregular)) + return irregular; + if (Invariants.Contains(word)) + return word; + if (word.EndsWith("ies", StringComparison.Ordinal) && word.Length > 3) + return word[..^3] + "y"; // Policies -> Policy + if (word.EndsWith("uses", StringComparison.Ordinal) && word.Length > 4) + return word[..^2]; // Statuses -> Status + if (EndsWithSibilantEs(word)) + return word[..^2]; // Businesses -> Business, Mailboxes -> Mailbox + if (word.EndsWith("ss", StringComparison.Ordinal) || word.EndsWith("us", StringComparison.Ordinal) || word.EndsWith("is", StringComparison.Ordinal)) + return word; // Access, Status, Analysis stay put + if (word.EndsWith('s')) + return word[..^1]; // Messages -> Message, Plans -> Plan + return word; + } + + private static bool EndsWithSibilantEs(string word) + { + if (!word.EndsWith("es", StringComparison.Ordinal) || word.Length < 4) + return false; + var stem = word[..^2]; + return stem.EndsWith('x') + || stem.EndsWith('z') + || stem.EndsWith("ch", StringComparison.Ordinal) + || stem.EndsWith("sh", StringComparison.Ordinal) + || stem.EndsWith("ss", StringComparison.Ordinal); + } + + private static bool IsAllUpper(string word) + { + foreach (var c in word) + { + if (char.IsLower(c)) + return false; + } + return true; + } +} diff --git a/src/kiota/Handlers/KiotaGenerateCommandHandler.cs b/src/kiota/Handlers/KiotaGenerateCommandHandler.cs index 4d38b7edf8..b88ea0fedc 100644 --- a/src/kiota/Handlers/KiotaGenerateCommandHandler.cs +++ b/src/kiota/Handlers/KiotaGenerateCommandHandler.cs @@ -163,7 +163,9 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio try { var builder = new KiotaBuilder(logger, Configuration.Generation, httpClient); - var result = await builder.GenerateClientAsync(cancellationToken).ConfigureAwait(false); + var result = Configuration.Generation.Language == GenerationLanguage.PowerShellWrapper + ? await builder.GeneratePowerShellWrapperAsync(cancellationToken).ConfigureAwait(false) + : await builder.GenerateClientAsync(cancellationToken).ConfigureAwait(false); if (result) { DisplaySuccess("Generation completed successfully"); diff --git a/tests/Kiota.Builder.Tests/PowerShellWrapper/NamingTests.cs b/tests/Kiota.Builder.Tests/PowerShellWrapper/NamingTests.cs new file mode 100644 index 0000000000..44acb7ddf5 --- /dev/null +++ b/tests/Kiota.Builder.Tests/PowerShellWrapper/NamingTests.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using Kiota.Builder.PowerShellWrapper; +using Xunit; + +namespace Kiota.Builder.Tests.PowerShellWrapper; + +// The expected values are the published Microsoft.Graph cmdlet names, taken from the +// MgCommandMetadata.json inventory that ships inside Microsoft.Graph.Authentication. +// Changing one of these is a deliberate break from SDK parity and belongs in the +// migration guide, not in a quiet test edit. +public sealed class SingularizerTests +{ + [Theory] + // ies -> y + [InlineData("Policies", "Policy")] + [InlineData("Categories", "Category")] + // uses -> us (Get-MgDeviceManagementDeviceConfigurationUserStatus) + [InlineData("Statuses", "Status")] + // sibilant es + [InlineData("Businesses", "Business")] + [InlineData("Mailboxes", "Mailbox")] + [InlineData("Branches", "Branch")] + // ss/us/is guard + [InlineData("Access", "Access")] + [InlineData("Status", "Status")] + [InlineData("Analysis", "Analysis")] + // plain s + [InlineData("Messages", "Message")] + [InlineData("Plans", "Plan")] + [InlineData("Settings", "Setting")] + [InlineData("Licenses", "License")] + // irregulars (Get-MgDriveItemChild, Get-MgUserPerson) + [InlineData("Children", "Child")] + [InlineData("People", "Person")] + // invariants (Get-MgUserSettingWindows) + [InlineData("Windows", "Windows")] + // acronyms are never plural forms + [InlineData("OS", "OS")] + public void SingularizesWords(string word, string expected) + { + Assert.Equal(expected, Singularizer.SingularizeWord(word)); + } + + [Theory] + [InlineData("Users", "User")] + [InlineData("ManagedDevices", "ManagedDevice")] + [InlineData("BookingBusinesses", "BookingBusiness")] + [InlineData("ReportSettings", "ReportSetting")] + [InlineData("ConditionalAccess", "ConditionalAccess")] + // per-word inflection, matching the published SDK's inflector: + // Update-MgDeviceManagementTermAndCondition + [InlineData("TermsAndConditions", "TermAndCondition")] + // Get-MgDirectoryOnPremiseSynchronization (interior word singularized) + [InlineData("OnPremisesSynchronization", "OnPremiseSynchronization")] + // version tag: Get-MgSecurityAlertV2 + [InlineData("Alerts_v2", "AlertV2")] + public void SingularizesSegments(string segment, string expected) + { + Assert.Equal(expected, Singularizer.SingularizeSegment(segment)); + } +} + +public sealed class NamingTests +{ + private static CmdletNaming Resolve(string method, string path) + { + var pathParams = new List(); + foreach (var segment in path.Split('/')) + { + if (segment.StartsWith('{') && segment.EndsWith('}')) + pathParams.Add(segment[1..^1]); + } + return Naming.Resolve(new OperationInfo(method, path, "test_op", pathParams, [], HasBody: false)); + } + + [Theory] + // pilot module goldens (Microsoft.Graph.* 2.37.0 names) + [InlineData("GET", "/users", "Get", "MgUser")] + [InlineData("GET", "/users/{user-id}", "Get", "MgUser")] + [InlineData("POST", "/users", "New", "MgUser")] + [InlineData("PATCH", "/users/{user-id}", "Update", "MgUser")] + [InlineData("DELETE", "/users/{user-id}", "Remove", "MgUser")] + [InlineData("GET", "/users/{user-id}/messages", "Get", "MgUserMessage")] + [InlineData("GET", "/users/{user-id}/messages/{message-id}", "Get", "MgUserMessage")] + [InlineData("GET", "/users/{user-id}/contacts", "Get", "MgUserContact")] + [InlineData("GET", "/applications/{application-id}", "Get", "MgApplication")] + [InlineData("GET", "/deviceManagement/managedDevices", "Get", "MgDeviceManagementManagedDevice")] + [InlineData("GET", "/identity/conditionalAccess/policies/{conditionalAccessPolicy-id}", "Get", "MgIdentityConditionalAccessPolicy")] + [InlineData("GET", "/planner/plans", "Get", "MgPlannerPlan")] + [InlineData("GET", "/security/alerts_v2", "Get", "MgSecurityAlertV2")] + [InlineData("PATCH", "/admin/reportSettings", "Update", "MgAdminReportSetting")] + [InlineData("GET", "/schemaExtensions", "Get", "MgSchemaExtension")] + [InlineData("GET", "/domains/{domain-id}", "Get", "MgDomain")] + [InlineData("GET", "/groups/{group-id}", "Get", "MgGroup")] + [InlineData("GET", "/teams/{team-id}", "Get", "MgTeam")] + // overrides mirroring the SDK's own AutoRest directives (see NamingOverrides) + [InlineData("GET", "/solutions/bookingBusinesses/{bookingBusiness-id}", "Get", "MgBookingBusiness")] + [InlineData("PATCH", "/solutions/bookingBusinesses/{bookingBusiness-id}", "Update", "MgBookingBusiness")] + [InlineData("GET", "/users/{user-id}/calendar", "Get", "MgUserDefaultCalendar")] + // boundary word-overlap collapse (Get-MgDomainNameReference) + [InlineData("GET", "/domains/{domain-id}/domainNameReferences", "Get", "MgDomainNameReference")] + // adjacent-duplicate collapse (Get-MgUserOnenoteSectionGroup... family) + [InlineData("GET", "/users/{user-id}/onenote/sectionGroups/{sectionGroup-id}/sectionGroups", "Get", "MgUserOnenoteSectionGroup")] + // OData cast segments (Get-MgGroupOwnerAsUser) + [InlineData("GET", "/groups/{group-id}/owners/{directoryObject-id}/graph.user", "Get", "MgGroupOwnerAsUser")] + public void ResolvesPublishedSdkNames(string method, string path, string expectedVerb, string expectedNoun) + { + var naming = Resolve(method, path); + Assert.Equal(expectedVerb, naming.VerbName); + Assert.Equal(expectedNoun, naming.Noun); + Assert.Equal($"{expectedVerb}{expectedNoun}Command", naming.ClassName); + } + + [Fact] + public void SuppressesOperationsThePublishedSdkOmits() + { + // Calendar.md remove-path-by-operation user_UpdateCalendar: no Update cmdlet ships for + // the default-calendar singleton. + Assert.True(NamingOverrides.IsSuppressed("PATCH", "/users/{user-id}/calendar")); + Assert.False(NamingOverrides.IsSuppressed("GET", "/users/{user-id}/calendar")); + Assert.False(NamingOverrides.IsSuppressed("PATCH", "/users/{user-id}/messages/{message-id}")); + } + + [Fact] + public void ListAndItemGetsShareTheNounSoDispatcherPairingSurvives() + { + var list = Resolve("GET", "/users/{user-id}/messages"); + var item = Resolve("GET", "/users/{user-id}/messages/{message-id}"); + Assert.Equal(list.Noun, item.Noun); + + var internalList = Naming.WithSuffix(list, "_List"); + Assert.Equal("MgUserMessage_List", internalList.Noun); + Assert.Equal("GetMgUserMessage_ListCommand", internalList.ClassName); + } +}