Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Kiota.Builder/GenerationLanguage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
37 changes: 32 additions & 5 deletions src/Kiota.Builder/KiotaBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -275,6 +281,27 @@ public async Task<bool> GeneratePluginAsync(CancellationToken cancellationToken,
}, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>Whether the generated code was updated or not</returns>
public async Task<bool> 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);
}

/// <summary>
/// Generates the code from the OpenAPI document
/// </summary>
Expand Down
679 changes: 679 additions & 0 deletions src/Kiota.Builder/PowerShellWrapper/CmdletEmitter.cs

Large diffs are not rendered by default.

151 changes: 151 additions & 0 deletions src/Kiota.Builder/PowerShellWrapper/CmdletNaming.cs
Original file line number Diff line number Diff line change
@@ -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<string> PathParamNames,
string BuilderExpression,
IReadOnlyList<HeaderParam> 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<string, (string VerbsClass, string VerbName)> 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<string>();
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<string> ExtractPathParamNames(string path)
{
var names = new List<string>();
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<string> 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();
}
}
9 changes: 9 additions & 0 deletions src/Kiota.Builder/PowerShellWrapper/EmitContext.cs
Original file line number Diff line number Diff line change
@@ -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";
}
98 changes: 98 additions & 0 deletions src/Kiota.Builder/PowerShellWrapper/NamingOverrides.cs
Original file line number Diff line number Diff line change
@@ -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<Entry> 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);
}
}
20 changes: 20 additions & 0 deletions src/Kiota.Builder/PowerShellWrapper/OperationInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Collections.Generic;

namespace Kiota.Builder.PowerShellWrapper;

public sealed record OperationInfo(
string HttpMethod,
string Path,
string OperationId,
IReadOnlyList<string> PathParams,
IReadOnlyList<string> 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<string>? HeaderParams = null);
Loading
Loading