From 2ce6ac808a69b2aacc20728810c15c12bf055ba6 Mon Sep 17 00:00:00 2001 From: Joywambui-maina Date: Tue, 28 Jul 2026 15:38:48 -0700 Subject: [PATCH 1/4] feat(wrapper-generator): add initial PowerShell wrapper generator with cmdlet-name parity checks and unit tests --- .gitignore | 1 + tools/Compare-WrapperCmdletNames.ps1 | 242 ++++++ tools/WrapperGenerator.Tests/EmitterTests.cs | 33 + .../GenerationServiceRegressionTests.cs | 149 ++++ tools/WrapperGenerator.Tests/NamingTests.cs | 188 +++++ .../SchemaPropertiesTests.cs | 96 +++ .../WrapperGenerator.Tests.csproj | 26 + tools/WrapperGenerator/CmdletEmitter.cs | 686 ++++++++++++++++++ tools/WrapperGenerator/CmdletNaming.cs | 177 +++++ tools/WrapperGenerator/EmitContext.cs | 9 + tools/WrapperGenerator/GeneratorConfig.cs | 5 + tools/WrapperGenerator/GeneratorExtensions.cs | 41 ++ tools/WrapperGenerator/IncludePathFilter.cs | 115 +++ tools/WrapperGenerator/NamingOverrides.cs | 105 +++ tools/WrapperGenerator/OperationInfo.cs | 20 + .../PowerShellWrapperGenerationService.cs | 370 ++++++++++ tools/WrapperGenerator/Program.cs | 92 +++ tools/WrapperGenerator/README.md | 174 +++++ tools/WrapperGenerator/SchemaProperties.cs | 84 +++ tools/WrapperGenerator/Singularizer.cs | 115 +++ .../WrapperGenerator/WrapperGenerator.csproj | 23 + 21 files changed, 2751 insertions(+) create mode 100644 tools/Compare-WrapperCmdletNames.ps1 create mode 100644 tools/WrapperGenerator.Tests/EmitterTests.cs create mode 100644 tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs create mode 100644 tools/WrapperGenerator.Tests/NamingTests.cs create mode 100644 tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs create mode 100644 tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj create mode 100644 tools/WrapperGenerator/CmdletEmitter.cs create mode 100644 tools/WrapperGenerator/CmdletNaming.cs create mode 100644 tools/WrapperGenerator/EmitContext.cs create mode 100644 tools/WrapperGenerator/GeneratorConfig.cs create mode 100644 tools/WrapperGenerator/GeneratorExtensions.cs create mode 100644 tools/WrapperGenerator/IncludePathFilter.cs create mode 100644 tools/WrapperGenerator/NamingOverrides.cs create mode 100644 tools/WrapperGenerator/OperationInfo.cs create mode 100644 tools/WrapperGenerator/PowerShellWrapperGenerationService.cs create mode 100644 tools/WrapperGenerator/Program.cs create mode 100644 tools/WrapperGenerator/README.md create mode 100644 tools/WrapperGenerator/SchemaProperties.cs create mode 100644 tools/WrapperGenerator/Singularizer.cs create mode 100644 tools/WrapperGenerator/WrapperGenerator.csproj diff --git a/.gitignore b/.gitignore index 3e41c4492f..137e52d5d1 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ +sweep_results.json # StyleCop StyleCopReport.xml diff --git a/tools/Compare-WrapperCmdletNames.ps1 b/tools/Compare-WrapperCmdletNames.ps1 new file mode 100644 index 0000000000..418790973d --- /dev/null +++ b/tools/Compare-WrapperCmdletNames.ps1 @@ -0,0 +1,242 @@ +<# +.SYNOPSIS +Parity gate for kiota's PowerShellWrapper generator. + +.DESCRIPTION +See the kiota repo: src/Kiota.Builder/PowerShellWrapper/README.md, sections 4 and 6. + +For every generated *.g.cs cmdlet file, reconstructs the HTTP method and URI from the +client..Async( call chain (CmdletEmitter.cs's templates; the +chain shape comes from CmdletNaming.BuildBuilderExpression), joins it against the +Method+Uri -> Command inventory in MgCommandMetadata.json, and reports whether the +emitted [Cmdlet(...)] name matches what the oracle says the published SDK calls that +operation. + +Dispatcher cmdlets (the paired-GET public cmdlet that only forwards to its internal +_List/_Get siblings via InvokeCommand.InvokeScript - see CmdletEmitter.EmitGetDispatcher) +contain no direct Graph call, so there is nothing to reconstruct from their source; they +are reported separately per module and excluded from the match ratio. Their two internal +siblings do carry real calls and are what actually gets checked against the oracle. + +Path parameter names are not exactly recoverable from the generated C#: CmdletNaming +.ExtractPathParamNames PascalCases each hyphen-chunk of a raw "{some-id}" segment and +concatenates with no separator kept, so e.g. "conditionalAccessPolicy-id" and a +hypothetical "conditionalAccess-policy-id" would both produce the indexer name +"ConditionalAccessPolicyId" - the hyphen position inside a multi-word param name is lost. +Fixed path segments do not have this problem (BuildBuilderExpression only ever uppercases +the first character of a whole segment, which is exactly reversible). So this script +compares fixed segments exactly and normalizes every path parameter, on both the +generated and the oracle side, to a single "{param}" placeholder before joining. + +The oracle carries both v1.0 and beta rows, and many operations have the identical +Method+URI in both (only the Command prefix differs: Get-MgUserMessage vs +Get-MgBetaUserMessage) - joining on Method+URI alone is ambiguous. Each module's +kiota-lock.json (written next to its *.g.cs files) records the descriptionLocation the +module was generated from, e.g. "../../openApiDocs/v1.0/Mail.yml"; this script reads the +v1.0/beta segment out of that path and scopes the oracle lookup to just that ApiVersion. +A module folder with no kiota-lock.json (or an unrecognized descriptionLocation) falls +back to searching every ApiVersion and reports a real ambiguity if more than one distinct +command turns up. + +.PARAMETER GeneratedPath +A folder of generated *.g.cs files for one module, or a folder containing one +subfolder per module (each with its own *.g.cs files) - e.g. the repo's generated/. + +.PARAMETER OraclePath +Path to MgCommandMetadata.json. + +.EXAMPLE +.\tools\Compare-WrapperCmdletNames.ps1 +.\tools\Compare-WrapperCmdletNames.ps1 -GeneratedPath generated\Mail +#> +[CmdletBinding()] +param( + [string]$GeneratedPath = (Join-Path $PSScriptRoot '..\generated'), + [string]$OraclePath = (Join-Path $PSScriptRoot '..\src\Authentication\Authentication\custom\common\MgCommandMetadata.json') +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $GeneratedPath)) { + Write-Error "Generated path not found: $GeneratedPath" + exit 1 +} +if (-not (Test-Path $OraclePath)) { + Write-Error "Oracle file not found: $OraclePath" + exit 1 +} + +# One folder full of *.g.cs directly, or one folder per module. Handles both so the script +# works whether -GeneratedPath points at generated/ or at generated/Mail directly. +function Get-WrapperModuleFolders { + param([string]$Root) + + $rootItem = Get-Item -Path $Root + $direct = Get-ChildItem -Path $rootItem.FullName -Filter '*.g.cs' -File -ErrorAction SilentlyContinue + if ($direct) { + return @([pscustomobject]@{ Name = $rootItem.Name; Path = $rootItem.FullName }) + } + + Get-ChildItem -Path $rootItem.FullName -Directory -ErrorAction SilentlyContinue | ForEach-Object { + if (Get-ChildItem -Path $_.FullName -Filter '*.g.cs' -File -ErrorAction SilentlyContinue) { + [pscustomobject]@{ Name = $_.Name; Path = $_.FullName } + } + } +} + +function ConvertTo-NormalizedOracleUri { + param([string]$Uri) + $parts = $Uri.Trim('/') -split '/' + $norm = foreach ($p in $parts) { + if ($p -match '^\{.*\}$') { '{param}' } else { $p } + } + '/' + ($norm -join '/') +} + +function ConvertTo-NormalizedGeneratedUri { + param([string]$BuilderExpression) + $segments = @() + foreach ($token in ($BuilderExpression -split '\.')) { + if ($token -notmatch '^([A-Za-z0-9]+)(\[([A-Za-z0-9]+)\])?$') { + return $null + } + $prop = $Matches[1] + $segments += ($prop.Substring(0, 1).ToLowerInvariant() + $prop.Substring(1)) + if ($Matches[3]) { $segments += '{param}' } + } + '/' + ($segments -join '/') +} + +# Reads the v1.0/beta segment out of a module's kiota-lock.json ("descriptionLocation": +# "../../openApiDocs/v1.0/Mail.yml"), so the oracle join can be scoped to the ApiVersion +# the module was actually generated from instead of guessing. +function Get-ModuleApiVersion { + param([string]$ModulePath) + $lockPath = Join-Path $ModulePath 'kiota-lock.json' + if (-not (Test-Path $lockPath)) { return $null } + $loc = (Get-Content -Path $lockPath -Raw | ConvertFrom-Json).descriptionLocation + if (-not $loc) { return $null } + if ($loc -match '(^|[\\/])v1\.0([\\/]|$)') { return 'v1.0' } + if ($loc -match '(^|[\\/])beta([\\/]|$)') { return 'beta' } + return $null +} + +Write-Host "Loading oracle from $OraclePath ..." +$oracle = Get-Content -Path $OraclePath -Raw | ConvertFrom-Json + +$oracleIndex = @{} +foreach ($entry in $oracle) { + if (-not $entry.Method -or -not $entry.Uri -or -not $entry.Command -or -not $entry.ApiVersion) { continue } + $key = "$($entry.ApiVersion)|$($entry.Method)|$(ConvertTo-NormalizedOracleUri $entry.Uri)" + if (-not $oracleIndex.ContainsKey($key)) { + $oracleIndex[$key] = [System.Collections.Generic.HashSet[string]]::new() + } + [void]$oracleIndex[$key].Add($entry.Command) +} +Write-Host "Indexed $($oracle.Count) oracle entries into $($oracleIndex.Count) ApiVersion+Method+URI keys." +Write-Host '' + +# Looks up one Method+URI, scoped to $ApiVersion when known; falls back to searching every +# ApiVersion (and merging what turns up) when the module's own version couldn't be +# determined, so an unresolvable version shows up as a real ambiguity, not a false match. +function Find-OracleCommands { + param([hashtable]$Index, [string]$ApiVersion, [string]$Method, [string]$NormalizedUri) + if ($ApiVersion) { + return $Index["$ApiVersion|$Method|$NormalizedUri"] + } + $merged = [System.Collections.Generic.HashSet[string]]::new() + foreach ($v in 'v1.0', 'beta') { + $found = $Index["$v|$Method|$NormalizedUri"] + if ($found) { [void]$merged.UnionWith($found) } + } + if ($merged.Count -eq 0) { return $null } + return $merged +} + +$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"([^"]+)"' +$callChainPattern = 'client\.([A-Za-z0-9_.\[\]]+)\.(Get|Post|Patch|Put|Delete)Async\(' + +$modules = @(Get-WrapperModuleFolders -Root $GeneratedPath) +if ($modules.Count -eq 0) { + Write-Error "No *.g.cs files found under $GeneratedPath" + exit 1 +} + +$totalJoinable = 0 +$totalMatched = 0 +$totalMismatches = 0 +$totalDispatchers = 0 + +foreach ($module in $modules | Sort-Object Name) { + $files = Get-ChildItem -Path $module.Path -Filter '*.g.cs' -File | Sort-Object Name + $apiVersion = Get-ModuleApiVersion -ModulePath $module.Path + $moduleJoinable = 0 + $moduleMatched = 0 + $moduleDispatchers = 0 + $moduleProblems = @() + + foreach ($file in $files) { + $content = Get-Content -Path $file.FullName -Raw + + $attrMatch = [regex]::Match($content, $cmdletAttrPattern) + if (-not $attrMatch.Success) { + continue # not a cmdlet file (Shared.g.cs) + } + $verb = $attrMatch.Groups[1].Value + $generatedNoun = $attrMatch.Groups[2].Value + $publishedNoun = $generatedNoun -replace '_(List|Get)$', '' + $generatedCommand = "$verb-$generatedNoun" + $expectedCommand = "$verb-$publishedNoun" + + $callMatch = [regex]::Match($content, $callChainPattern) + if (-not $callMatch.Success) { + $moduleDispatchers++ + continue # dispatcher: delegates to internal cmdlets, nothing to reconstruct here + } + + $builderExpr = $callMatch.Groups[1].Value + $method = $callMatch.Groups[2].Value.ToUpperInvariant() + $normalizedUri = ConvertTo-NormalizedGeneratedUri -BuilderExpression $builderExpr + + $moduleJoinable++ + + if (-not $normalizedUri) { + $moduleProblems += " [UNPARSEABLE] $($file.Name): builder expression '$builderExpr' has a segment this script doesn't recognize (e.g. an OData cast segment - see README section 7, cast endpoints aren't generated yet)." + continue + } + + $candidates = Find-OracleCommands -Index $oracleIndex -ApiVersion $apiVersion -Method $method -NormalizedUri $normalizedUri + + if (-not $candidates -or $candidates.Count -eq 0) { + $moduleProblems += " [NO ORACLE ENTRY] $($file.Name): '$generatedCommand' -> reconstructed $method $normalizedUri, no oracle row for that Method+URI." + } + elseif ($candidates.Count -gt 1) { + $moduleProblems += " [AMBIGUOUS] $($file.Name): $method $normalizedUri matches multiple oracle commands: $($candidates -join ', ')." + } + elseif ($candidates.Contains($expectedCommand)) { + $moduleMatched++ + } + else { + $moduleProblems += " [MISMATCH] $($file.Name): generated '$expectedCommand', oracle says '$($candidates | Select-Object -First 1)' for $method $normalizedUri." + } + } + + $status = if ($moduleJoinable -eq 0) { 'n/a' } else { "$moduleMatched of $moduleJoinable" } + $dispatcherNote = if ($moduleDispatchers -gt 0) { " (+$moduleDispatchers dispatcher cmdlet(s), no direct call to verify)" } else { '' } + $versionNote = if ($apiVersion) { " [$apiVersion]" } else { ' [ApiVersion unknown - searched all versions]' } + Write-Host "$($module.Name)$($versionNote): $status cmdlets match the oracle$dispatcherNote" + foreach ($line in $moduleProblems) { Write-Host $line -ForegroundColor Yellow } + + $totalJoinable += $moduleJoinable + $totalMatched += $moduleMatched + $totalMismatches += $moduleProblems.Count + $totalDispatchers += $moduleDispatchers +} + +Write-Host '' +Write-Host "TOTAL: $totalMatched of $totalJoinable cmdlets match the oracle across $($modules.Count) module(s) (+$totalDispatchers dispatcher cmdlet(s) skipped)." + +if ($totalMismatches -gt 0) { + exit 1 +} +exit 0 diff --git a/tools/WrapperGenerator.Tests/EmitterTests.cs b/tools/WrapperGenerator.Tests/EmitterTests.cs new file mode 100644 index 0000000000..a6b9b86aff --- /dev/null +++ b/tools/WrapperGenerator.Tests/EmitterTests.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using WrapperGenerator; +using Xunit; + +namespace WrapperGenerator.Tests; + +public sealed class EmitterTests +{ + // A spec-derived noun or header name containing a double quote must be escaped where it is + // interpolated into a generated C# string literal, or the generated source will not compile. + [Fact] + public void EscapesQuotesInSpecDerivedStringLiterals() + { + var naming = new CmdletNaming( + VerbsClass: "VerbsCommon", + VerbName: "Remove", + Noun: "MgHe\"llo", // quote lands in the [Cmdlet(..., "...")] literal + ClassName: "RemoveMgHelloCommand", // class name is a separate, valid identifier + PathParamNames: new[] { "UserId" }, + BuilderExpression: "Users[UserId]", + HeaderParams: new[] { new HeaderParam("If\"Match", "IfMatch") }); // quote in a header name + + var source = CmdletEmitter.EmitRemove(naming, new EmitContext("Test.Client")); + + // Escaped forms present (valid literals). + Assert.Contains("\"MgHe\\\"llo\"", source); // "MgHe\"llo" + Assert.Contains("\"If\\\"Match\"", source); // "If\"Match" + + // Broken/unescaped forms absent. + Assert.DoesNotContain("\"MgHe\"llo\"", source); // "MgHe"llo" + Assert.DoesNotContain("\"If\"Match\"", source); // "If"Match" + } +} diff --git a/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs b/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs new file mode 100644 index 0000000000..271049ab62 --- /dev/null +++ b/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.OpenApi; +using WrapperGenerator; +using Xunit; + +namespace WrapperGenerator.Tests; + +public sealed class GenerationServiceRegressionTests +{ + [Fact] + public async Task GenerateAsync_SkipsGetWithoutJsonSuccessSchema_DoesNotThrow() + { + var operation = new OpenApiOperation + { + OperationId = "user_get_plain_text", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Content = new Dictionary + { + ["text/plain"] = new OpenApiMediaType + { + Schema = new OpenApiSchema { Type = JsonSchemaType.String }, + }, + }, + }, + }, + }; + + var document = BuildDocument(HttpMethod.Get, "/users/{user-id}", operation); + var files = await RunGeneratorAndListFilesAsync(document); + + Assert.Contains("Shared.g.cs", files); + Assert.DoesNotContain(files, f => f != "Shared.g.cs"); + } + + [Fact] + public async Task GenerateAsync_SkipsUnsupportedHttpMethod_DoesNotThrow() + { + var operation = new OpenApiOperation + { + OperationId = "user_set_photo_value", + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["displayName"] = new OpenApiSchema { Type = JsonSchemaType.String }, + }, + }, + }, + }, + }, + }; + + var document = BuildDocument(HttpMethod.Put, "/users/{user-id}/photo/$value", operation); + var files = await RunGeneratorAndListFilesAsync(document); + + Assert.Contains("Shared.g.cs", files); + Assert.DoesNotContain(files, f => f != "Shared.g.cs"); + } + + [Fact] + public async Task GenerateAsync_SkipsPostWithNonRefBodySchema_DoesNotThrow() + { + var operation = new OpenApiOperation + { + OperationId = "user_create_inline", + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + // Inline object schema (no $ref) previously caused entity-type resolution crashes. + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["displayName"] = new OpenApiSchema { Type = JsonSchemaType.String }, + }, + }, + }, + }, + }, + }; + + var document = BuildDocument(HttpMethod.Post, "/users", operation); + var files = await RunGeneratorAndListFilesAsync(document); + + Assert.Contains("Shared.g.cs", files); + Assert.DoesNotContain(files, f => f != "Shared.g.cs"); + } + + private static OpenApiDocument BuildDocument(HttpMethod method, string path, OpenApiOperation operation) + { + return new OpenApiDocument + { + Paths = new OpenApiPaths + { + [path] = new OpenApiPathItem + { + Operations = new Dictionary + { + [method] = operation, + }, + }, + }, + }; + } + + private static async Task RunGeneratorAndListFilesAsync(OpenApiDocument document) + { + var outputDir = Path.Combine(Path.GetTempPath(), "wrapper-generator-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outputDir); + + try + { + var config = new GeneratorConfig("Microsoft.Graph.PowerShell.Test.Client", outputDir); + var service = new PowerShellWrapperGenerationService(document, config, NullLogger.Instance); + + await service.GenerateAsync(CancellationToken.None); + + var files = Directory.GetFiles(outputDir, "*.g.cs", SearchOption.TopDirectoryOnly); + for (var i = 0; i < files.Length; i++) + files[i] = Path.GetFileName(files[i]); + return files; + } + finally + { + if (Directory.Exists(outputDir)) + Directory.Delete(outputDir, recursive: true); + } + } +} diff --git a/tools/WrapperGenerator.Tests/NamingTests.cs b/tools/WrapperGenerator.Tests/NamingTests.cs new file mode 100644 index 0000000000..6ef40d424f --- /dev/null +++ b/tools/WrapperGenerator.Tests/NamingTests.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using WrapperGenerator; +using Xunit; + +namespace WrapperGenerator.Tests; + +// 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); + } + + [Theory] + // The builder expression is the Kiota request-builder chain the emitted cmdlet calls + // (client..GetAsync()). A property per fixed segment, an indexer per path parameter. + [InlineData("/users", "Users")] + [InlineData("/users/{user-id}", "Users[UserId]")] + [InlineData("/users/{user-id}/messages", "Users[UserId].Messages")] + [InlineData("/users/{user-id}/messages/{message-id}", "Users[UserId].Messages[MessageId]")] + [InlineData("/deviceManagement/managedDevices", "DeviceManagement.ManagedDevices")] + [InlineData("/identity/conditionalAccess/policies/{conditionalAccessPolicy-id}", "Identity.ConditionalAccess.Policies[ConditionalAccessPolicyId]")] + // OData cast segments: a dotted segment is one Kiota builder member, not a member access. + // Verified against a real Kiota C# client: microsoft.graph.user -> the MicrosoftGraphUser + // request-builder property; graph.user (KiotaCompat form) -> GraphUser by the same rule. + [InlineData("/groups/{group-id}/owners/{directoryObject-id}/graph.user", "Groups[GroupId].Owners[DirectoryObjectId].GraphUser")] + [InlineData("/groups/{group-id}/owners/{directoryObject-id}/microsoft.graph.user", "Groups[GroupId].Owners[DirectoryObjectId].MicrosoftGraphUser")] + public void BuildsKiotaBuilderExpression(string path, string expectedExpression) + { + Assert.Equal(expectedExpression, Resolve("GET", path).BuilderExpression); + } + + [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); + } + + [Fact] + public void CleanListItemPairMerges() + { + // A list GET and its item GET one id deeper share a noun and fit structurally, so they + // merge into the single public Get-MgUserMessage dispatcher. + var list = Resolve("GET", "/users/{user-id}/messages"); + var item = Resolve("GET", "/users/{user-id}/messages/{message-id}"); + Assert.Equal(list.Noun, item.Noun); + Assert.True(Naming.IsListItemPair(list, item)); + } + + [Fact] + public void GetWithNoStructuralPartnerStaysStandalone() + { + // A list under one resource and an item under a different resource neither share a noun + // nor fit structurally, so nothing merges — the list is emitted as a standalone cmdlet. + var contactsList = Resolve("GET", "/users/{user-id}/contacts"); + var messageItem = Resolve("GET", "/users/{user-id}/messages/{message-id}"); + Assert.NotEqual(contactsList.Noun, messageItem.Noun); + Assert.False(Naming.IsListItemPair(contactsList, messageItem)); + } + + [Fact] + public void AmbiguousSameNounDoesNotMerge() + { + // Self-referential /sites: the collection /sites/{id}/sites and the single /sites/{id} + // both resolve to MgSite, but the "item" is the parent, not a child one id deeper, so + // the structural check rejects the merge and both stay standalone. + var list = Resolve("GET", "/sites/{site-id}/sites"); + var item = Resolve("GET", "/sites/{site-id}"); + Assert.Equal(list.Noun, item.Noun); + Assert.False(Naming.IsListItemPair(list, item)); + } +} diff --git a/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs b/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs new file mode 100644 index 0000000000..5b1909196d --- /dev/null +++ b/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi; +using WrapperGenerator; +using Xunit; + +namespace WrapperGenerator.Tests; + +public sealed class SchemaPropertiesTests +{ + private static OpenApiSchema Scalar(JsonSchemaType type, bool readOnly = false) => + new() { Type = type, ReadOnly = readOnly }; + + [Fact] + public void KeepsPrimitivesAndPrimitiveArrays_ExcludesServerManagedAndComplex() + { + var body = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + // kept + ["displayName"] = Scalar(JsonSchemaType.String), + ["accountEnabled"] = Scalar(JsonSchemaType.Boolean), + ["jobTitle"] = Scalar(JsonSchemaType.Integer), + ["businessPhones"] = new OpenApiSchema { Type = JsonSchemaType.Array, Items = Scalar(JsonSchemaType.String) }, + // excluded + ["id"] = Scalar(JsonSchemaType.String), // server-assigned + ["@odata.type"] = Scalar(JsonSchemaType.String), // @-prefixed OData control + ["createdDateTime"] = Scalar(JsonSchemaType.String, readOnly: true), // ReadOnly + ["assignedLicenses"] = new OpenApiSchema // nested complex + { + Type = JsonSchemaType.Object, + Properties = new Dictionary { ["skuId"] = Scalar(JsonSchemaType.String) }, + }, + }, + }; + + var names = SchemaProperties.ExtractPrimitiveProperties(body).Select(p => p.OpenApiName).ToHashSet(); + + Assert.Contains("displayName", names); + Assert.Contains("accountEnabled", names); + Assert.Contains("jobTitle", names); + Assert.Contains("businessPhones", names); + + Assert.DoesNotContain("id", names); + Assert.DoesNotContain("@odata.type", names); + Assert.DoesNotContain("createdDateTime", names); + Assert.DoesNotContain("assignedLicenses", names); + } + + [Fact] + public void MapsScalarAndArrayShapes() + { + var body = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["displayName"] = Scalar(JsonSchemaType.String), + ["businessPhones"] = new OpenApiSchema { Type = JsonSchemaType.Array, Items = Scalar(JsonSchemaType.String) }, + }, + }; + + var props = SchemaProperties.ExtractPrimitiveProperties(body); + + var scalar = props.Single(p => p.OpenApiName == "displayName"); + Assert.False(scalar.IsArray); + Assert.Equal("string", scalar.PsTypeName); + Assert.Equal("DisplayName", scalar.PascalName); + + var array = props.Single(p => p.OpenApiName == "businessPhones"); + Assert.True(array.IsArray); + Assert.Equal("string[]", array.PsTypeName); + Assert.Equal("BusinessPhones", array.PascalName); + } + + [Fact] + public void HasPasswordProfile_DetectsDirectAndViaAllOf() + { + var withProfile = new OpenApiSchema + { + Properties = new Dictionary { ["passwordProfile"] = new OpenApiSchema { Type = JsonSchemaType.Object } }, + }; + Assert.True(SchemaProperties.HasPasswordProfile(withProfile)); + + var viaAllOf = new OpenApiSchema { AllOf = new List { withProfile } }; + Assert.True(SchemaProperties.HasPasswordProfile(viaAllOf)); + + var without = new OpenApiSchema + { + Properties = new Dictionary { ["displayName"] = Scalar(JsonSchemaType.String) }, + }; + Assert.False(SchemaProperties.HasPasswordProfile(without)); + } +} diff --git a/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj b/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj new file mode 100644 index 0000000000..a3634244bc --- /dev/null +++ b/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + Exe + disable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tools/WrapperGenerator/CmdletEmitter.cs b/tools/WrapperGenerator/CmdletEmitter.cs new file mode 100644 index 0000000000..ae31ed6fdf --- /dev/null +++ b/tools/WrapperGenerator/CmdletEmitter.cs @@ -0,0 +1,686 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace WrapperGenerator; + +// 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); + """; + + // Escapes a spec-derived value so it is safe inside a double-quoted C# string literal in the + // generated source: backslash first, then double-quote. Applied to values taken from the + // OpenAPI document (the cmdlet noun and header names) wherever they land inside "..." — an + // unescaped quote or backslash would otherwise produce source that does not compile. + private static string EscapeLiteral(string value) => + value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + + 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 '{{EscapeLiteral(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("{{EscapeLiteral(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}}, "{{EscapeLiteral(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}}, "{{EscapeLiteral(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, "{{EscapeLiteral(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" ? "{{EscapeLiteral(internalGetCmdletName)}}" : "{{EscapeLiteral(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}}, "{{EscapeLiteral(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}}, "{{EscapeLiteral(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}}, "{{EscapeLiteral(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/tools/WrapperGenerator/CmdletNaming.cs b/tools/WrapperGenerator/CmdletNaming.cs new file mode 100644 index 0000000000..22c146169f --- /dev/null +++ b/tools/WrapperGenerator/CmdletNaming.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace WrapperGenerator; + +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(ToBuilderMemberName(segment)); + } + first = false; + } + + return expression.ToString(); + } + + // A fixed path segment becomes one Kiota request-builder member. An OData cast segment + // carries dots ("microsoft.graph.user", or "graph.user" in the KiotaCompat specs); Kiota + // maps it to a single member by upper-casing each dot-separated part and concatenating, so + // "microsoft.graph.user" is the "MicrosoftGraphUser" property, "graph.user" is "GraphUser" + // (verified against a generated Kiota C# client). Emitting the raw segment would leave a + // stray dot ("Graph.user") — invalid C# and not a real builder member. Non-cast segments + // have no dots and are unchanged. NOTE: cast endpoints are not generated end to end yet + // (tracked follow-up), so this keeps the expression a valid identifier chain until then. + private static string ToBuilderMemberName(string segment) => + segment.Contains('.', StringComparison.Ordinal) + ? string.Concat(segment.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(part => part.ToFirstCharacterUpperCase())) + : segment.ToFirstCharacterUpperCase(); + + // Whether a list GET and an item GET form a mergeable pair for the public Get-MgX + // dispatcher: the item's path must be the list's path plus exactly one trailing id + // (Users[UserId].Messages -> Users[UserId].Messages[MessageId]). Callers group by noun + // first, so this only decides the structural fit; a same-noun item that does not extend the + // list (for example the self-referential /sites/{id} vs /sites/{id}/sites) is rejected. + public static bool IsListItemPair(CmdletNaming list, CmdletNaming item) + { + ArgumentNullException.ThrowIfNull(list); + ArgumentNullException.ThrowIfNull(item); + return item.PathParamNames.Count == list.PathParamNames.Count + 1 + && item.PathParamNames.Take(list.PathParamNames.Count).SequenceEqual(list.PathParamNames) + && item.BuilderExpression.StartsWith(list.BuilderExpression + "[", StringComparison.Ordinal); + } +} diff --git a/tools/WrapperGenerator/EmitContext.cs b/tools/WrapperGenerator/EmitContext.cs new file mode 100644 index 0000000000..9a738aa7f9 --- /dev/null +++ b/tools/WrapperGenerator/EmitContext.cs @@ -0,0 +1,9 @@ +namespace WrapperGenerator; + +// 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/tools/WrapperGenerator/GeneratorConfig.cs b/tools/WrapperGenerator/GeneratorConfig.cs new file mode 100644 index 0000000000..0b8f90c796 --- /dev/null +++ b/tools/WrapperGenerator/GeneratorConfig.cs @@ -0,0 +1,5 @@ +namespace WrapperGenerator; + +// Configuration for a generation run. The generation service reads exactly two values: the +// client namespace the module is generated with, and the output folder for the .g.cs files. +public sealed record GeneratorConfig(string ClientNamespaceName, string OutputPath); diff --git a/tools/WrapperGenerator/GeneratorExtensions.cs b/tools/WrapperGenerator/GeneratorExtensions.cs new file mode 100644 index 0000000000..6afdad3165 --- /dev/null +++ b/tools/WrapperGenerator/GeneratorExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using Microsoft.OpenApi; + +namespace WrapperGenerator; + +// Small string and OpenAPI-schema helpers used across the generator. +internal static class GeneratorExtensions +{ + public static string ToFirstCharacterLowerCase(this string? input) + => string.IsNullOrEmpty(input) ? string.Empty : char.ToLowerInvariant(input[0]) + input[1..]; + + public static string ToFirstCharacterUpperCase(this string? input) + => string.IsNullOrEmpty(input) ? string.Empty : char.ToUpperInvariant(input[0]) + input[1..]; + + private static readonly char[] defaultSeparators = ['-']; + + public static string ToPascalCase(this string? input, params char[] separators) => ToInternalCamelCase(input, separators, true); + + private static string ToInternalCamelCase(string? input, char[] separators, bool firstCharacterUpperCase = false, bool normalizeFirstCharacter = true) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + if (separators is null || separators.Length == 0) separators = defaultSeparators; + var chunks = input.Split(separators, StringSplitOptions.RemoveEmptyEntries); + if (chunks.Length == 0) return string.Empty; + return ((normalizeFirstCharacter, firstCharacterUpperCase) switch + { + (false, _) => chunks[0], + (true, true) => chunks[0].ToFirstCharacterUpperCase(), + (true, false) => chunks[0].ToFirstCharacterLowerCase() + }) + + string.Join(string.Empty, chunks.Skip(1).Select(ToFirstCharacterUpperCase)); + } + + // Resolves the schema name for a $ref: a referenced schema is an OpenApiSchemaReference and + // carries the id. (Kiota's own reader can also recover an id from an allOf-merged schema via a + // marker its refiner stamps on, but this generator does not run that refiner, so no such marker + // is ever present and there is nothing more to resolve.) + internal static string? GetReferenceId(this IOpenApiSchema schema) => + schema is OpenApiSchemaReference reference ? reference.Reference?.Id : null; +} diff --git a/tools/WrapperGenerator/IncludePathFilter.cs b/tools/WrapperGenerator/IncludePathFilter.cs new file mode 100644 index 0000000000..fc1126e5d6 --- /dev/null +++ b/tools/WrapperGenerator/IncludePathFilter.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.OpenApi; + +namespace WrapperGenerator; + +// Trims the OpenAPI document to the requested paths/methods before generation (the generation +// service just walks whatever paths remain). +// +// Each --include-path is "#METHOD1,METHOD2" (the "#..." method list is optional). A path +// is kept if it matches any pattern's glob; on a kept path, only operations whose HTTP method +// is in that pattern's method set survive (methods are unioned across every pattern that +// matches the path, and a pattern with no "#..." keeps all methods). With no --include-path +// supplied, the document is left untouched — i.e. a cmdlet is generated for every operation, +// which is the "feed an already-filtered document" mode. +// +// Glob semantics, matched against the templated path keys (e.g. "/users/{user-id}/messages"): +// ** -> any characters, including "/" +// * -> any run of non-"/" characters (stays within one segment) +// [s] -> a character class, passed through to the regex (so "message[s]" matches "messages") +// everything else (including "{", "}", "-") is matched literally +// This is intentionally minimal: it covers the operators the current include patterns use, not +// a full glob surface. +internal static class IncludePathFilter +{ + private sealed record Pattern(Regex PathRegex, HashSet Methods); + + public static void Apply(OpenApiDocument document, IReadOnlyList includePaths) + { + if (includePaths.Count == 0 || document.Paths is null) + return; + + var patterns = includePaths.Select(Parse).ToList(); + + foreach (var pathKey in document.Paths.Keys.ToList()) + { + var allowedMethods = new HashSet(StringComparer.OrdinalIgnoreCase); + var allowAllMethods = false; + var matchedAny = false; + + foreach (var pattern in patterns) + { + if (!pattern.PathRegex.IsMatch(pathKey)) + continue; + matchedAny = true; + if (pattern.Methods.Count == 0) + allowAllMethods = true; + else + allowedMethods.UnionWith(pattern.Methods); + } + + if (!matchedAny) + { + document.Paths.Remove(pathKey); + continue; + } + + if (allowAllMethods) + continue; + + var operations = document.Paths[pathKey].Operations; + if (operations is null) + continue; + foreach (var method in operations.Keys.ToList()) + { + if (!allowedMethods.Contains(method.Method)) + operations.Remove(method); + } + } + } + + private static Pattern Parse(string includePath) + { + var hashIndex = includePath.IndexOf('#'); + var glob = hashIndex >= 0 ? includePath[..hashIndex] : includePath; + var methodsPart = hashIndex >= 0 ? includePath[(hashIndex + 1)..] : string.Empty; + + var methods = methodsPart + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return new Pattern(new Regex(GlobToRegex(glob), RegexOptions.IgnoreCase), methods); + } + + private static string GlobToRegex(string glob) + { + var sb = new StringBuilder("^"); + for (var i = 0; i < glob.Length; i++) + { + var c = glob[i]; + switch (c) + { + case '*' when i + 1 < glob.Length && glob[i + 1] == '*': + sb.Append(".*"); + i++; + break; + case '*': + sb.Append("[^/]*"); + break; + case '[': + case ']': + sb.Append(c); // pass a glob character class straight through to the regex + break; + default: + sb.Append(Regex.Escape(c.ToString())); + break; + } + } + sb.Append('$'); + return sb.ToString(); + } +} diff --git a/tools/WrapperGenerator/NamingOverrides.cs b/tools/WrapperGenerator/NamingOverrides.cs new file mode 100644 index 0000000000..803e961a20 --- /dev/null +++ b/tools/WrapperGenerator/NamingOverrides.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace WrapperGenerator; + +// 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"), + + // Most nouns under /solutions/ drop the "Solution" prefix (for example + // Get-MgBookingBusiness, Get-MgVirtualEventWebinar). BackupRestore is a known + // exception where published cmdlets keep the Solution prefix. + new(OverrideKind.StripNounPrefix, Method: null, "/solutions/", ExactPath: false, Value: "Solution", + Reason: "Bookings/VirtualEvents naming pattern under /solutions/*; BackupRestore is explicitly excluded in ApplyNounOverrides"), + ]; + + [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); + + // Published BackupRestore cmdlets retain the Solution prefix (for example, + // Get-MgSolutionBackupRestore). Do not apply /solutions/* strip rules here. + var skipSolutionStrip = path.StartsWith("/solutions/backuprestore", StringComparison.Ordinal); + + foreach (var entry in Entries) + { + if (!Matches(entry, httpMethod, path)) + continue; + switch (entry.Kind) + { + case OverrideKind.ReplaceNoun: + return entry.Value!; + case OverrideKind.StripNounPrefix when skipSolutionStrip: + break; + 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/tools/WrapperGenerator/OperationInfo.cs b/tools/WrapperGenerator/OperationInfo.cs new file mode 100644 index 0000000000..e8ede2787d --- /dev/null +++ b/tools/WrapperGenerator/OperationInfo.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace WrapperGenerator; + +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/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs b/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs new file mode 100644 index 0000000000..3316b54e81 --- /dev/null +++ b/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; + +namespace WrapperGenerator; + +// 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 GeneratorConfig config; + private readonly ILogger logger; + + public PowerShellWrapperGenerationService(OpenApiDocument document, GeneratorConfig 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. + // + // The "2XX" response key and "application/json" content type are intentional, + // Graph-scoped assumptions, not a generic OpenAPI reader: the Graph metadata keys + // every success response as "2XX" and describes JSON bodies. This generator only + // targets Graph, so the direct indexers are deliberate. A general reader would + // need to fall back across 200/201/default and negotiate content types. + var responseSchema = httpMethod == HttpMethod.Get + ? TryGetSuccessJsonSchema(operation) + : 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); + + if (httpMethod == HttpMethod.Get && responseSchema is null) + { + LogSkippedUnsupportedOperation(httpMethod.Method, pathTemplate, "missing supported success JSON response schema"); + continue; + } + + // 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), + _ => null, + }; + + if (source is null) + { + var reason = httpMethod == HttpMethod.Delete || httpMethod == HttpMethod.Post || httpMethod == HttpMethod.Patch + ? "missing supported request JSON schema" + : "no wrapper emitter for this HTTP method"; + LogSkippedUnsupportedOperation(httpMethod.Method, pathTemplate, reason); + continue; + } + + 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 (!Naming.IsListItemPair(listOp.Naming, itemOp.Naming)) + continue; + + if (!TryResolveListEntityTypeName(listOp.CollectionValueSchema!, ctx.ModelsNamespace, out var listEntityType) + || !TryResolveEntityTypeName(itemOp.ResponseSchema, ctx.ModelsNamespace, out var entityType)) + { + LogSkippedUnsupportedOperation("GET", listOp.Naming.BuilderExpression, "response schema is not a resolvable $ref entity type"); + continue; + } + 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; + + string? source; + if (op.IsCollection) + { + if (!TryResolveListEntityTypeName(op.CollectionValueSchema!, ctx.ModelsNamespace, out var listEntityType)) + { + LogSkippedUnsupportedOperation("GET", op.Naming.BuilderExpression, "collection response schema is not a resolvable $ref entity type"); + continue; + } + + source = CmdletEmitter.EmitListGet(op.Naming, ctx, listEntityType, listEntityType + "CollectionResponse", op.QueryParams.ToHashSet()); + } + else + { + if (!TryResolveEntityTypeName(op.ResponseSchema, ctx.ModelsNamespace, out var entityType)) + { + LogSkippedUnsupportedOperation("GET", op.Naming.BuilderExpression, "response schema is not a resolvable $ref entity type"); + continue; + } + + source = CmdletEmitter.EmitItemGet(op.Naming, ctx, entityType); + } + + 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); + [LoggerMessage(Level = LogLevel.Warning, Message = "Skipped {Method} {PathTemplate}: {Reason}")] + private partial void LogSkippedUnsupportedOperation(string method, string pathTemplate, string reason); + + // collectionValueSchema is the already-resolved "value" array property from + // GetOperationRecord, so nothing is re-walked here. + private static bool TryResolveListEntityTypeName(IOpenApiSchema collectionValueSchema, string modelsNamespace, out string entityTypeName) + { + entityTypeName = string.Empty; + var itemSchema = collectionValueSchema.Items; + if (itemSchema is null) + return false; + return TryResolveEntityTypeName(itemSchema, modelsNamespace, out entityTypeName); + } + + // 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) + { + // "application/json" is an intentional, Graph-scoped assumption: Graph request bodies are + // JSON, so the content type is indexed directly rather than negotiated. See the matching + // note on the response lookup in GenerateAsync. + var bodySchema = TryGetRequestJsonSchema(operation); + if (bodySchema is null) + return null; + if (!TryResolveEntityTypeName(bodySchema, ctx.ModelsNamespace, out var entityType)) + return null; + return CmdletEmitter.EmitNew(naming, ctx, entityType, + SchemaProperties.ExtractPrimitiveProperties(bodySchema), SchemaProperties.HasPasswordProfile(bodySchema)); + } + + private static string? EmitUpdateFor(CmdletNaming naming, EmitContext ctx, OpenApiOperation operation) + { + // "application/json" is an intentional, Graph-scoped assumption (see EmitNewFor). + var bodySchema = TryGetRequestJsonSchema(operation); + if (bodySchema is null) + return null; + if (!TryResolveEntityTypeName(bodySchema, ctx.ModelsNamespace, out var entityType)) + return null; + return CmdletEmitter.EmitUpdate(naming, ctx, entityType, + SchemaProperties.ExtractPrimitiveProperties(bodySchema), SchemaProperties.HasPasswordProfile(bodySchema)); + } + + private static IOpenApiSchema? TryGetSuccessJsonSchema(OpenApiOperation operation) + { + if (operation.Responses is null) + return null; + + foreach (var key in new[] { "2XX", "200", "201", "default" }) + { + if (!operation.Responses.TryGetValue(key, out var response) || response?.Content is null) + continue; + + var schema = TryGetJsonSchemaFromContent(response.Content); + if (schema is not null) + return schema; + } + + return null; + } + + private static IOpenApiSchema? TryGetRequestJsonSchema(OpenApiOperation operation) + { + var content = operation.RequestBody?.Content; + if (content is null) + return null; + return TryGetJsonSchemaFromContent(content); + } + + private static IOpenApiSchema? TryGetJsonSchemaFromContent(IDictionary content) + { + if (content.TryGetValue("application/json", out var exactJson) && exactJson?.Schema is not null) + return exactJson.Schema; + + foreach (var (contentType, mediaType) in content) + { + if (contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) + || contentType.EndsWith("+json", StringComparison.OrdinalIgnoreCase)) + { + if (mediaType?.Schema is not null) + return mediaType.Schema; + } + } + + return null; + } + + private static bool TryResolveEntityTypeName(IOpenApiSchema schema, string modelsNamespace, out string entityTypeName) + { + entityTypeName = string.Empty; + var id = schema.GetReferenceId(); + if (string.IsNullOrEmpty(id)) + return false; + entityTypeName = SchemaNameToTypeName(id, modelsNamespace); + return true; + } + + 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/tools/WrapperGenerator/Program.cs b/tools/WrapperGenerator/Program.cs new file mode 100644 index 0000000000..837357fc7c --- /dev/null +++ b/tools/WrapperGenerator/Program.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +namespace WrapperGenerator; + +// Entry point: loads an OpenAPI document from a file, applies --include-path filtering, builds +// the run configuration, and calls PowerShellWrapperGenerationService.GenerateAsync. +internal static class Program +{ + private static readonly JsonSerializerOptions LockFileJsonOptions = new() { WriteIndented = true }; + + private static async Task Main(string[] args) + { + string? specPath = null; + string? outputPath = null; + string? clientNamespace = null; + var includePaths = new List(); + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "-d" or "--openapi": + specPath = ArgValue(args, ref i); + break; + case "-o" or "--output": + outputPath = ArgValue(args, ref i); + break; + case "-n" or "--namespace" or "--namespace-name": + clientNamespace = ArgValue(args, ref i); + break; + case "--include-path": + includePaths.Add(ArgValue(args, ref i)); + break; + case "-c" or "--class-name": + // Accepted for parity with the kiota command line; the emitter hardcodes the + // client type name, so this tool does not use it. + ArgValue(args, ref i); + break; + default: + Console.Error.WriteLine($"Unknown argument: {args[i]}"); + return 2; + } + } + + if (specPath is null || outputPath is null || clientNamespace is null) + { + Console.Error.WriteLine( + "Usage: WrapperGenerator -d -o -n [--include-path '#GET,POST' ...]"); + return 2; + } + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + + await using var stream = File.OpenRead(specPath); + var readResult = await OpenApiDocument.LoadAsync(stream, settings: settings, cancellationToken: CancellationToken.None).ConfigureAwait(false); + var document = readResult.Document + ?? throw new InvalidOperationException($"Failed to parse an OpenAPI document from '{specPath}'."); + + IncludePathFilter.Apply(document, includePaths); + + var config = new GeneratorConfig(ClientNamespaceName: clientNamespace, OutputPath: outputPath); + var service = new PowerShellWrapperGenerationService(document, config, NullLogger.Instance); + await service.GenerateAsync(CancellationToken.None).ConfigureAwait(false); + + // The generation service writes only *.g.cs. Also write a minimal kiota-lock.json recording + // the source spec path, so downstream tooling that keys off it — notably + // tools/Compare-WrapperCmdletNames.ps1, which reads the v1.0/beta segment out of it to scope + // its oracle join — can determine the API version. + var lockJson = JsonSerializer.Serialize(new { descriptionLocation = specPath }, LockFileJsonOptions); + await File.WriteAllTextAsync(Path.Combine(outputPath, "kiota-lock.json"), lockJson, CancellationToken.None).ConfigureAwait(false); + + var count = Directory.GetFiles(outputPath, "*.g.cs").Length; + Console.WriteLine($"Generated {count} .g.cs file(s) to {outputPath}"); + return 0; + } + + private static string ArgValue(string[] args, ref int i) + { + if (i + 1 >= args.Length) + throw new ArgumentException($"Missing value after '{args[i]}'."); + return args[++i]; + } +} diff --git a/tools/WrapperGenerator/README.md b/tools/WrapperGenerator/README.md new file mode 100644 index 0000000000..963593ef42 --- /dev/null +++ b/tools/WrapperGenerator/README.md @@ -0,0 +1,174 @@ +# WrapperGenerator + +Generates the PowerShell **cmdlets** for the Microsoft Graph SDK from Graph's OpenAPI description — the C# classes behind commands like `Get-MgUserMessage` and `New-MgUserMessage`. + +## Why this exists + +The Microsoft Graph PowerShell SDK is thousands of cmdlets, and customers have scripts that depend on their exact names — `Get-MgUserMessage`, not `Get-MgUsersMessages`. Those names follow conventions, but the conventions are fiddly (singular nouns, a `Mg` prefix, a handful of hand-tuned exceptions), and the SDK's current generator (AutoRest) has quietly dropped cmdlets when names collided. + +This tool regenerates those cmdlets from the same OpenAPI description **while reproducing the published names exactly**, so a regenerated module is a drop-in replacement. Because name parity is the hard part, most of the tool is a naming engine; the rest is a straightforward C# code emitter. + +## What it produces + +You give it operations from the OpenAPI document; it writes one C# file per cmdlet. For the four Mail operations on messages, the mapping is: + +| OpenAPI operation | Cmdlet | +|---|---| +| `GET /users/{user-id}/messages` (a list) | `Get-MgUserMessage` | +| `GET /users/{user-id}/messages/{message-id}` (one item) | `Get-MgUserMessage` | +| `POST /users/{user-id}/messages` | `New-MgUserMessage` | +| `DELETE /users/{user-id}/messages/{message-id}` | `Remove-MgUserMessage` | + +Each cmdlet is a `PSCmdlet` subclass that authenticates, binds parameters, and calls Graph through the Kiota-generated C# client. A user runs `Get-MgUserMessage -UserId ` and gets their messages back. + +## How a cmdlet name is built + +This is the core of the tool. A name has three parts: a **verb**, a **noun**, and the `Mg` prefix. + +**Verb — from the HTTP method:** + +| HTTP | Verb | | HTTP | Verb | +|---|---|---|---|---| +| GET | `Get` | | PUT | `Set` | +| POST | `New` | | DELETE | `Remove` | +| PATCH | `Update` | | | | + +**Noun — from the URL path, not the operationId.** The operationId in the spec carries whatever plurality and casing the spec author chose; the URL path is deterministic. So the noun is built by walking the path: + +1. Drop the `{parameter}` segments. `/users/{user-id}/messages` → `users` `messages`. +2. Pascal-case and **singularize each remaining segment**: `User`, `Message`. +3. Collapse repeats and stitch together, prefix `Mg`: `MgUserMessage`. + +Singularization runs per camel-case word (so `termsAndConditions` → `TermAndCondition`), through an ordered list of rules — first match wins: + +| Rule | Example | +|---|---| +| Acronyms / words under 3 letters stay | `OS` → `OS` | +| Irregulars | `Children` → `Child`, `People` → `Person` | +| Invariants (never singularized) | `Windows` → `Windows` | +| `ies` → `y` | `Policies` → `Policy` | +| `uses` → `us` | `Statuses` → `Status` | +| `es` after x/z/ch/sh/ss | `Businesses` → `Business` | +| ends in `ss`/`us`/`is` stays | `Access`, `Status`, `Analysis` | +| trailing `s` drops | `Messages` → `Message` | + +A few published names aren't algorithmic — they come from hand-written directives in the SDK's module configs. Those live as data in `NamingOverrides.cs`, each with a cited source, rather than as special cases in the naming code. There are three today: suppress `PATCH /users/{id}/calendar` (the SDK ships no such cmdlet), rename `GET /users/{id}/calendar` to `…UserDefaultCalendar`, and strip the `Solution` prefix for most `/solutions/*` nouns (for example, `Get-MgBookingBusiness`, not `Get-MgSolutionBookingBusiness`) while preserving it for known exceptions such as BackupRestore (`Get-MgSolutionBackupRestore`). + +## The one subtle part: list + item GET become one cmdlet + +Graph has two GETs for a resource — the collection (`GET …/messages`) and a single item (`GET …/messages/{message-id}`) — but the published SDK exposes **one** cmdlet, `Get-MgUserMessage`,that does both: no `-MessageId` lists them, a `-MessageId` fetches one. + +The generator reproduces that. When it finds a list GET and an item GET that share a noun, it emits **three** files: + +- `Get-MgUserMessage` — a thin **dispatcher**. It has two parameter sets, `List` (default) and `Get`, and makes no Graph call itself; it just forwards to one of the two cmdlets below. +- `Get-MgUserMessage_List` — the real list implementation. +- `Get-MgUserMessage_Get` — the real single-item implementation. + +The dispatcher is small enough to read in full — this is the actual generated output: + +```csharp +[Cmdlet(VerbsCommon.Get, "MgUserMessage", DefaultParameterSetName = "List")] +[OutputType(typeof(MessageCollectionResponse), ParameterSetName = new[] { "List" })] +[OutputType(typeof(Message), ParameterSetName = new[] { "Get" })] +public class GetMgUserMessageCommand : PSCmdlet +{ + [Parameter(Mandatory = true, Position = 0)] + public string UserId { get; set; } = string.Empty; + [Parameter(Mandatory = true, ParameterSetName = "Get", Position = 1)] + public string MessageId { get; set; } = string.Empty; + // ... -Filter/-Top/-Skip/... on the "List" set, -Property/-Expand on both ... + + protected override void ProcessRecord() + { + var internalCmdletName = ParameterSetName == "Get" ? "Get-MgUserMessage_Get" : "Get-MgUserMessage_List"; + InvokeCommand.InvokeScript( + "param($BoundParameters, $CmdletName) & $CmdletName @BoundParameters", + /* ... */ MyInvocation.BoundParameters, internalCmdletName); + } +} +``` + +`-MessageId` belongs only to the `Get` set, so binding it selects that set and the dispatcher calls `Get-MgUserMessage_Get`; otherwise it calls `Get-MgUserMessage_List`. (Standalone GETs with no list/item pair — like a singleton `GET /users/{id}/calendar` — just get one plain cmdlet.) + +## What's inside every cmdlet + +Whatever the shape, a generated cmdlet has the same skeleton: + +- **Path IDs** become mandatory positional parameters (`-UserId`, `-MessageId`). +- **Auth**: every cmdlet takes an optional `-AccessToken`; without it, the cmdlet uses the active + `Connect-MgGraph` session. (The shared auth helpers are written once per module into `Shared.g.cs`.) +- **GETs** expose the OData query options the operation supports — `-Filter`, `-Property` (alias`-Select`), `-Sort` (alias `-OrderBy`), `-Top`, `-Skip`, `-Count`. +- **`New`/`Update`** flatten the request body's top-level primitive properties into parameters (`-Subject`, `-IsRead`, …). Nested/complex properties are skipped, with one special case: + `passwordProfile` is exposed as `-Password`/`-ForceChangePasswordNextSignIn` because creating a user requires it. `Update` also re-fetches after a `204 No Content` so it still returns the updated object. +- **`New`/`Update`/`Remove`** are gated by `ShouldProcess`, so `-WhatIf` and `-Confirm` work. +- **The actual request** is the Kiota client's fluent chain built from the path: + `client.Users[UserId].Messages[MessageId].GetAsync(...)`. + +## What it needs to actually run + +The generated files are **source, not a built module**. They reference, by name, a Kiota-generated C# client (an `ApiClient` type and its `Models`) plus the Graph auth helpers. So this tool is step 2 +of a two-step build: + +``` +Filtered OpenAPI (Graph) + ├─► [1] kiota generate ─► request builders + models (the "ApiClient" — run separately) + └─► [2] WrapperGenerator ─► the cmdlet wrappers (this tool) +``` + +The wrappers compile and run only alongside step 1's output. Wiring the two into one buildable module is later work (see Gaps). + +## The source files + +| File | What it does | +|---|---| +| `Program.cs` | CLI entry point: load the spec, filter paths, run the generator | +| `IncludePathFilter.cs` | Trims the spec to the requested `--include-path` paths/methods | +| `PowerShellWrapperGenerationService.cs` | The orchestrator: walks the paths, pairs list/item GETs, writes the files | +| `CmdletNaming.cs` | Verb + noun + the `client.X[Y].Z` request chain | +| `Singularizer.cs` | The per-word singularization rules | +| `NamingOverrides.cs` | The three hand-cited name exceptions | +| `CmdletEmitter.cs` | The C# templates for each cmdlet shape (the actual code text) | +| `SchemaProperties.cs` | Which body properties become `New`/`Update` parameters | +| `OperationInfo.cs`, `EmitContext.cs`, `GeneratorConfig.cs` | Small data/config carriers | +| `GeneratorExtensions.cs` | String + schema helper methods | + +## Build, run, test + +**Build:** + +```powershell dotnet build tools/WrapperGenerator +``` + +**Run** — generate the Mail message cmdlets (the `--include-path` args pick which operations): + +```powershell +dotnet run --project tools/WrapperGenerator -- ` + -d openApiDocs/v1.0/Mail.yml ` + -o ` + -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' +``` + +`-d` is the spec, `-o` the output folder, `-n` the namespace of the step-1 client the wrappers call. Each `--include-path` is a glob with an optional `#METHOD,METHOD` filter; omit them to generate every operation in the document. Output: `Shared.g.cs`, one `*.g.cs` per cmdlet (in namespace `MgPoC`), and a small `kiota-lock.json` noting the source spec. + +**Test** — two layers: + +```powershell +# 1. Naming rules pinned to published Microsoft.Graph names (69 tests) +dotnet test tools/WrapperGenerator.Tests +# => Passed! - Failed: 0, Passed: 69, Total: 69 + +# 2. Parity gate: generate, then check every cmdlet name against Graph's own command inventory +.\tools\Compare-WrapperCmdletNames.ps1 -GeneratedPath +# => Mail [v1.0]: 4 of 4 cmdlets match the oracle ... EXIT CODE: 0 +``` + +The unit tests guard the naming rules (their expected values are real published names from `src/Authentication/Authentication/custom/common/MgCommandMetadata.json`). The parity gate checks actual generated output against that same inventory. There is **no** test yet that the generated cmdlets *compile* — that needs step 1's client to compile against. + +## Gaps / not done yet + +- **Output isn't wired into a module.** Files go to whatever `-o` folder you pass, in a fixed `MgPoC` namespace. The target design commits wrappers into `src/{Module}/` with a per-module namespace; that alignment (and a namespace override) isn't built. +- **No runtime base classes or real auth flow.** Shared paging, a proper `Connect-MgGraph`/session integration, and base cmdlet classes are a later phase. +- **Body binding is shallow** — top-level primitive properties only; no nested/complex types beyond the `passwordProfile` special case. +- **Some operation shapes aren't generated** — `$count`/`$ref`/`$value`, delta, OData actions/functions, and cast endpoints. diff --git a/tools/WrapperGenerator/SchemaProperties.cs b/tools/WrapperGenerator/SchemaProperties.cs new file mode 100644 index 0000000000..f8b15322ce --- /dev/null +++ b/tools/WrapperGenerator/SchemaProperties.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OpenApi; + +namespace WrapperGenerator; + +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/tools/WrapperGenerator/Singularizer.cs b/tools/WrapperGenerator/Singularizer.cs new file mode 100644 index 0000000000..c00957ecc8 --- /dev/null +++ b/tools/WrapperGenerator/Singularizer.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace WrapperGenerator; + +// 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/tools/WrapperGenerator/WrapperGenerator.csproj b/tools/WrapperGenerator/WrapperGenerator.csproj new file mode 100644 index 0000000000..ee22043de8 --- /dev/null +++ b/tools/WrapperGenerator/WrapperGenerator.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + disable + enable + latest + WrapperGenerator + WrapperGenerator + false + + + + + + + + + + From 2600080887f2bae46fe080ff88f5df372e4e95e8 Mon Sep 17 00:00:00 2001 From: Joywambui-maina Date: Wed, 29 Jul 2026 13:16:36 -0700 Subject: [PATCH 2/4] ci: install .NET 10 SDK in pipeline to support net10.0 wrapper generator --- .azure-pipelines/common-templates/install-tools.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.azure-pipelines/common-templates/install-tools.yml b/.azure-pipelines/common-templates/install-tools.yml index 0b64943de1..0e8feaf9fd 100644 --- a/.azure-pipelines/common-templates/install-tools.yml +++ b/.azure-pipelines/common-templates/install-tools.yml @@ -5,7 +5,12 @@ steps: - task: UseDotNet@2 displayName: Use .NET SDK inputs: - version: 8.x + version: 10.x + + - task: UseDotNet@2 + displayName: Use .NET SDK + inputs: + version: 8.x - task: UseDotNet@2 displayName: Use .NET SDK From d89f9f29af5714b3f447c3f07eb93afe73f9b206 Mon Sep 17 00:00:00 2001 From: Joywambui-maina Date: Fri, 31 Jul 2026 15:47:35 -0700 Subject: [PATCH 3/4] refactor: address PR review feedback and harden the wrapper generator - Use HttpMethod keys and a typed PsVerb set instead of raw strings for verb mapping - Centralize the Mg noun prefix in GeneratorConstants for future configurability - Document the Command class-name convention and the naming helpers' contracts; rename ToCastAwareBuilderMemberName - Singularize OData cast type names and extract TryBuildCastSegmentNoun (tested) - Suppress GET/PATCH /solutions per Bookings.md remove-path-by-operation directive - Skip unsupported OData path segments ($count/$value/$ref, parameterized functions) instead of emitting malformed or non-compiling cmdlets - Fix numeric type mapping: number -> double, int64 -> long (was int, data loss) - Log skip diagnostics to stderr in CLI runs (was NullLogger, skips invisible) - Remove dead code: ResolveReferenceId, unread OperationInfo properties, unused casing path, orphaned .gitignore entry - Dispatcher ErrorRecord target now follows the active parameter set (Copilot) - Parity script: handle escaped noun literals (Copilot); report OData cast chains as skips instead of missing oracle entries (Copilot); fail on non-dispatcher files with no reconstructable call; refresh stale doc references - Name the SDK versions in pipeline displayNames 78 tests passing; parity green on Mail 69/69, Bookings 129/129, BackupRestore 183/183 (3 casts skipped) --- .../common-templates/install-tools.yml | 8 +- .gitignore | 1 - tools/Compare-WrapperCmdletNames.ps1 | 46 ++++++--- tools/WrapperGenerator.Tests/EmitterTests.cs | 3 +- .../GenerationServiceRegressionTests.cs | 34 +++++++ tools/WrapperGenerator.Tests/NamingTests.cs | 40 +++++--- .../SchemaPropertiesTests.cs | 27 ++++- tools/WrapperGenerator/CmdletEmitter.cs | 14 +-- tools/WrapperGenerator/CmdletNaming.cs | 99 +++++++++++++------ tools/WrapperGenerator/GeneratorConstants.cs | 9 ++ tools/WrapperGenerator/GeneratorExtensions.cs | 26 ++--- tools/WrapperGenerator/NamingOverrides.cs | 23 +++-- tools/WrapperGenerator/OperationInfo.cs | 17 ++-- .../PowerShellWrapperGenerationService.cs | 53 ++++++---- tools/WrapperGenerator/Program.cs | 3 +- tools/WrapperGenerator/README.md | 2 +- tools/WrapperGenerator/SchemaProperties.cs | 13 ++- tools/WrapperGenerator/StderrLogger.cs | 21 ++++ 18 files changed, 302 insertions(+), 137 deletions(-) create mode 100644 tools/WrapperGenerator/GeneratorConstants.cs create mode 100644 tools/WrapperGenerator/StderrLogger.cs diff --git a/.azure-pipelines/common-templates/install-tools.yml b/.azure-pipelines/common-templates/install-tools.yml index 0e8feaf9fd..7416022761 100644 --- a/.azure-pipelines/common-templates/install-tools.yml +++ b/.azure-pipelines/common-templates/install-tools.yml @@ -3,19 +3,19 @@ steps: - task: UseDotNet@2 - displayName: Use .NET SDK + displayName: Use .NET 10 SDK inputs: version: 10.x - task: UseDotNet@2 - displayName: Use .NET SDK + displayName: Use .NET 8 SDK inputs: version: 8.x - task: UseDotNet@2 - displayName: Use .NET SDK + displayName: Use .NET 6 SDK inputs: - version: 6.x + version: 6.x - task: NuGetToolInstaller@1 displayName: Install Nuget diff --git a/.gitignore b/.gitignore index 137e52d5d1..3e41c4492f 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,6 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ -sweep_results.json # StyleCop StyleCopReport.xml diff --git a/tools/Compare-WrapperCmdletNames.ps1 b/tools/Compare-WrapperCmdletNames.ps1 index 418790973d..fcf8c7a28c 100644 --- a/tools/Compare-WrapperCmdletNames.ps1 +++ b/tools/Compare-WrapperCmdletNames.ps1 @@ -1,9 +1,9 @@ <# .SYNOPSIS -Parity gate for kiota's PowerShellWrapper generator. +Parity gate for the WrapperGenerator cmdlet generator. .DESCRIPTION -See the kiota repo: src/Kiota.Builder/PowerShellWrapper/README.md, sections 4 and 6. +See tools/WrapperGenerator/README.md ("How a cmdlet name is built" and "Build, run, test"). For every generated *.g.cs cmdlet file, reconstructs the HTTP method and URI from the client..Async( call chain (CmdletEmitter.cs's templates; the @@ -101,6 +101,11 @@ function ConvertTo-NormalizedGeneratedUri { return $null } $prop = $Matches[1] + # Kiota cast builder members (MicrosoftGraphUser, or GraphUser from the KiotaCompat + # specs' "graph.user" form) cannot be translated back to the oracle's URI spelling + # ("microsoft.graph.user"), so cast chains are unparseable here and get reported as + # skipped at the call site until cast endpoints are supported end to end. + if ($prop -match '^(MicrosoftGraph|Graph)[A-Z]') { return $null } $segments += ($prop.Substring(0, 1).ToLowerInvariant() + $prop.Substring(1)) if ($Matches[3]) { $segments += '{param}' } } @@ -153,7 +158,9 @@ function Find-OracleCommands { return $merged } -$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"([^"]+)"' +# The emitter escapes spec-derived nouns for C# string literals (CmdletEmitter.EscapeLiteral), +# so the pattern accepts escaped sequences and the noun is unescaped after matching. +$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"((?:\\.|[^"\\])*)"' $callChainPattern = 'client\.([A-Za-z0-9_.\[\]]+)\.(Get|Post|Patch|Put|Delete)Async\(' $modules = @(Get-WrapperModuleFolders -Root $GeneratedPath) @@ -166,6 +173,7 @@ $totalJoinable = 0 $totalMatched = 0 $totalMismatches = 0 $totalDispatchers = 0 +$totalUnparseable = 0 foreach ($module in $modules | Sort-Object Name) { $files = Get-ChildItem -Path $module.Path -Filter '*.g.cs' -File | Sort-Object Name @@ -173,6 +181,8 @@ foreach ($module in $modules | Sort-Object Name) { $moduleJoinable = 0 $moduleMatched = 0 $moduleDispatchers = 0 + $moduleUnparseable = 0 + $moduleSkips = @() $moduleProblems = @() foreach ($file in $files) { @@ -183,28 +193,39 @@ foreach ($module in $modules | Sort-Object Name) { continue # not a cmdlet file (Shared.g.cs) } $verb = $attrMatch.Groups[1].Value - $generatedNoun = $attrMatch.Groups[2].Value + $generatedNoun = [regex]::Unescape($attrMatch.Groups[2].Value) $publishedNoun = $generatedNoun -replace '_(List|Get)$', '' $generatedCommand = "$verb-$generatedNoun" $expectedCommand = "$verb-$publishedNoun" $callMatch = [regex]::Match($content, $callChainPattern) if (-not $callMatch.Success) { - $moduleDispatchers++ - continue # dispatcher: delegates to internal cmdlets, nothing to reconstruct here + # Only a real dispatcher (forwards via InvokeScript) legitimately has no Graph + # call. Anything else with no reconstructable call is a malformed emission and + # must fail the gate instead of hiding in the dispatcher bucket. + if ($content -match 'InvokeCommand\.InvokeScript') { + $moduleDispatchers++ + continue # dispatcher: delegates to internal cmdlets, nothing to reconstruct here + } + $moduleJoinable++ + $moduleProblems += " [NO CALL] $($file.Name): contains no reconstructable Graph call and is not a dispatcher - likely a malformed emission." + continue } $builderExpr = $callMatch.Groups[1].Value $method = $callMatch.Groups[2].Value.ToUpperInvariant() $normalizedUri = ConvertTo-NormalizedGeneratedUri -BuilderExpression $builderExpr - $moduleJoinable++ - if (-not $normalizedUri) { - $moduleProblems += " [UNPARSEABLE] $($file.Name): builder expression '$builderExpr' has a segment this script doesn't recognize (e.g. an OData cast segment - see README section 7, cast endpoints aren't generated yet)." + # Known-unsupported shapes (OData cast chains) are reported but excluded from the + # match ratio and do not fail the gate, mirroring how dispatchers are handled. + $moduleUnparseable++ + $moduleSkips += " [UNPARSEABLE] $($file.Name): builder expression '$builderExpr' contains an OData cast segment - cast endpoints aren't generated end to end yet, so there is nothing to verify." continue } + $moduleJoinable++ + $candidates = Find-OracleCommands -Index $oracleIndex -ApiVersion $apiVersion -Method $method -NormalizedUri $normalizedUri if (-not $candidates -or $candidates.Count -eq 0) { @@ -223,18 +244,21 @@ foreach ($module in $modules | Sort-Object Name) { $status = if ($moduleJoinable -eq 0) { 'n/a' } else { "$moduleMatched of $moduleJoinable" } $dispatcherNote = if ($moduleDispatchers -gt 0) { " (+$moduleDispatchers dispatcher cmdlet(s), no direct call to verify)" } else { '' } + $castNote = if ($moduleUnparseable -gt 0) { " (+$moduleUnparseable cast cmdlet(s) skipped, not generated end to end yet)" } else { '' } $versionNote = if ($apiVersion) { " [$apiVersion]" } else { ' [ApiVersion unknown - searched all versions]' } - Write-Host "$($module.Name)$($versionNote): $status cmdlets match the oracle$dispatcherNote" + Write-Host "$($module.Name)$($versionNote): $status cmdlets match the oracle$dispatcherNote$castNote" + foreach ($line in $moduleSkips) { Write-Host $line -ForegroundColor DarkYellow } foreach ($line in $moduleProblems) { Write-Host $line -ForegroundColor Yellow } $totalJoinable += $moduleJoinable $totalMatched += $moduleMatched $totalMismatches += $moduleProblems.Count $totalDispatchers += $moduleDispatchers + $totalUnparseable += $moduleUnparseable } Write-Host '' -Write-Host "TOTAL: $totalMatched of $totalJoinable cmdlets match the oracle across $($modules.Count) module(s) (+$totalDispatchers dispatcher cmdlet(s) skipped)." +Write-Host "TOTAL: $totalMatched of $totalJoinable cmdlets match the oracle across $($modules.Count) module(s) (+$totalDispatchers dispatcher cmdlet(s) skipped, +$totalUnparseable cast cmdlet(s) skipped)." if ($totalMismatches -gt 0) { exit 1 diff --git a/tools/WrapperGenerator.Tests/EmitterTests.cs b/tools/WrapperGenerator.Tests/EmitterTests.cs index a6b9b86aff..12a4edf54e 100644 --- a/tools/WrapperGenerator.Tests/EmitterTests.cs +++ b/tools/WrapperGenerator.Tests/EmitterTests.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using WrapperGenerator; +using WrapperGenerator; using Xunit; namespace WrapperGenerator.Tests; diff --git a/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs b/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs index 271049ab62..fb5f07a471 100644 --- a/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs +++ b/tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs @@ -106,6 +106,40 @@ public async Task GenerateAsync_SkipsPostWithNonRefBodySchema_DoesNotThrow() Assert.DoesNotContain(files, f => f != "Shared.g.cs"); } + [Theory] + // $count would produce a mangled noun (Get-MgUsercount); $value an invalid builder chain + // (client...$value does not compile); parameterized function segments mangle into garbage + // nouns. All are skipped up front until those shapes are supported. + [InlineData("/users/$count")] + [InlineData("/users/{user-id}/photo/$value")] + [InlineData("/solutions/virtualEvents/townhalls/getByUserIdAndRole(userId='{userId}',role='{role}')")] + public async Task GenerateAsync_SkipsUnsupportedODataPathSegments_DoesNotEmitMalformedCmdlets(string path) + { + var operation = new OpenApiOperation + { + OperationId = "unsupported_shape", + Responses = new OpenApiResponses + { + ["2XX"] = new OpenApiResponse + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = new OpenApiSchema { Type = JsonSchemaType.Object }, + }, + }, + }, + }, + }; + + var document = BuildDocument(HttpMethod.Get, path, operation); + var files = await RunGeneratorAndListFilesAsync(document); + + Assert.Contains("Shared.g.cs", files); + Assert.DoesNotContain(files, f => f != "Shared.g.cs"); + } + private static OpenApiDocument BuildDocument(HttpMethod method, string path, OpenApiOperation operation) { return new OpenApiDocument diff --git a/tools/WrapperGenerator.Tests/NamingTests.cs b/tools/WrapperGenerator.Tests/NamingTests.cs index 6ef40d424f..c466606d90 100644 --- a/tools/WrapperGenerator.Tests/NamingTests.cs +++ b/tools/WrapperGenerator.Tests/NamingTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Net.Http; using WrapperGenerator; using Xunit; @@ -62,16 +62,8 @@ public void SingularizesSegments(string segment, string expected) 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)); - } + private static CmdletNaming Resolve(string method, string path) => + Naming.Resolve(new OperationInfo(new HttpMethod(method), path)); [Theory] // pilot module goldens (Microsoft.Graph.* 2.37.0 names) @@ -135,9 +127,29 @@ 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}")); + Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Patch, "/users/{user-id}/calendar")); + Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/users/{user-id}/calendar")); + Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Patch, "/users/{user-id}/messages/{message-id}")); + + // Bookings.md remove-path-by-operation ^solution\.solutionsRoot.*$: the /solutions root + // singleton ships no cmdlets, but its children are untouched. + Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/solutions")); + Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Patch, "/solutions")); + Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/solutions/bookingBusinesses/{bookingBusiness-id}")); + } + + [Theory] + // Both cast prefixes the specs use resolve to the same noun part. + [InlineData("graph.user", "AsUser")] + [InlineData("microsoft.graph.user", "AsUser")] + // A plural cast type is singularized rather than assumed singular (review hardening). + [InlineData("microsoft.graph.users", "AsUser")] + // Non-cast segments are not cast nouns. + [InlineData("messages", null)] + [InlineData("users", null)] + public void BuildsCastSegmentNouns(string segment, string? expected) + { + Assert.Equal(expected, Naming.TryBuildCastSegmentNoun(segment)); } [Fact] diff --git a/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs b/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs index 5b1909196d..5ee8eef3c3 100644 --- a/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs +++ b/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs @@ -8,8 +8,8 @@ namespace WrapperGenerator.Tests; public sealed class SchemaPropertiesTests { - private static OpenApiSchema Scalar(JsonSchemaType type, bool readOnly = false) => - new() { Type = type, ReadOnly = readOnly }; + private static OpenApiSchema Scalar(JsonSchemaType type, bool readOnly = false, string? format = null) => + new() { Type = type, ReadOnly = readOnly, Format = format }; [Fact] public void KeepsPrimitivesAndPrimitiveArrays_ExcludesServerManagedAndComplex() @@ -75,6 +75,29 @@ public void MapsScalarAndArrayShapes() Assert.Equal("BusinessPhones", array.PascalName); } + [Fact] + public void MapsNumericFormatsWithoutDataLoss() + { + var body = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["riskScore"] = Scalar(JsonSchemaType.Number), // fractions must survive + ["sizeInBytes"] = Scalar(JsonSchemaType.Integer, format: "int64"), // values > 2^31 must survive + ["retryCount"] = Scalar(JsonSchemaType.Integer, format: "int32"), + ["plainCount"] = Scalar(JsonSchemaType.Integer), + }, + }; + + var props = SchemaProperties.ExtractPrimitiveProperties(body); + + Assert.Equal("double", props.Single(p => p.OpenApiName == "riskScore").PsTypeName); + Assert.Equal("long", props.Single(p => p.OpenApiName == "sizeInBytes").PsTypeName); + Assert.Equal("int", props.Single(p => p.OpenApiName == "retryCount").PsTypeName); + Assert.Equal("int", props.Single(p => p.OpenApiName == "plainCount").PsTypeName); + } + [Fact] public void HasPasswordProfile_DetectsDirectAndViaAllOf() { diff --git a/tools/WrapperGenerator/CmdletEmitter.cs b/tools/WrapperGenerator/CmdletEmitter.cs index ae31ed6fdf..79914e69b1 100644 --- a/tools/WrapperGenerator/CmdletEmitter.cs +++ b/tools/WrapperGenerator/CmdletEmitter.cs @@ -173,7 +173,7 @@ public static string EmitItemGet(CmdletNaming naming, EmitContext ctx, string en namespace {{ctx.CmdletNamespace}} { - [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}")] + [Cmdlet({{naming.VerbsClass}}.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}")] [OutputType(typeof({{entityType}}))] public class {{naming.ClassName}} : PSCmdlet { @@ -278,7 +278,7 @@ public static string EmitListGet(CmdletNaming naming, EmitContext ctx, string en namespace {{ctx.CmdletNamespace}} { - [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}")] + [Cmdlet({{naming.VerbsClass}}.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}")] [OutputType(typeof({{entityType}}))] public class {{naming.ClassName}} : PSCmdlet { @@ -383,7 +383,7 @@ public static string EmitGetDispatcher(CmdletNaming listNaming, CmdletNaming ite namespace {{ctx.CmdletNamespace}} { - [Cmdlet(VerbsCommon.Get, "{{EscapeLiteral(listNaming.Noun)}}", DefaultParameterSetName = "List")] + [Cmdlet({{listNaming.VerbsClass}}.{{listNaming.VerbName}}, "{{EscapeLiteral(listNaming.Noun)}}", DefaultParameterSetName = "List")] [OutputType(typeof({{collectionResponseType}}), ParameterSetName = new[] { "List" })] [OutputType(typeof({{entityType}}), ParameterSetName = new[] { "Get" })] public class {{listNaming.ClassName}} : PSCmdlet @@ -420,7 +420,7 @@ protected override void ProcessRecord() null, MyInvocation.BoundParameters, internalCmdletName); } -{{CatchBlock(TargetId(itemNaming))}} +{{CatchBlock($"ParameterSetName == \"Get\" ? {TargetId(itemNaming)} : {TargetId(listNaming)}")}} } } } @@ -448,7 +448,7 @@ public static string EmitNew(CmdletNaming naming, EmitContext ctx, string entity namespace {{ctx.CmdletNamespace}} { - [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] + [Cmdlet({{naming.VerbsClass}}.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] [OutputType(typeof({{entityType}}))] public class {{naming.ClassName}} : PSCmdlet { @@ -505,7 +505,7 @@ public static string EmitUpdate(CmdletNaming naming, EmitContext ctx, string ent namespace {{ctx.CmdletNamespace}} { - [Cmdlet(VerbsData.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] + [Cmdlet({{naming.VerbsClass}}.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] [OutputType(typeof({{entityType}}))] public class {{naming.ClassName}} : PSCmdlet { @@ -572,7 +572,7 @@ public static string EmitRemove(CmdletNaming naming, EmitContext ctx) namespace {{ctx.CmdletNamespace}} { - [Cmdlet(VerbsCommon.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [Cmdlet({{naming.VerbsClass}}.{{naming.VerbName}}, "{{EscapeLiteral(naming.Noun)}}", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] public class {{naming.ClassName}} : PSCmdlet { {{PathParams(naming)}} diff --git a/tools/WrapperGenerator/CmdletNaming.cs b/tools/WrapperGenerator/CmdletNaming.cs index 22c146169f..6cf4339687 100644 --- a/tools/WrapperGenerator/CmdletNaming.cs +++ b/tools/WrapperGenerator/CmdletNaming.cs @@ -1,11 +1,24 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; namespace WrapperGenerator; public sealed record HeaderParam(string RawName, string PsName); +// A PowerShell verb as the [Cmdlet] attribute needs it: the Verbs* class that declares it +// (Update lives in VerbsData, the rest in VerbsCommon) and the verb name itself. Kept as a +// closed set of instances rather than raw strings so a typo cannot invent a verb. +public sealed record PsVerb(string AttributeClass, string Name) +{ + public static readonly PsVerb Get = new("VerbsCommon", "Get"); + public static readonly PsVerb New = new("VerbsCommon", "New"); + public static readonly PsVerb Update = new("VerbsData", "Update"); + public static readonly PsVerb Set = new("VerbsCommon", "Set"); + public static readonly PsVerb Remove = new("VerbsCommon", "Remove"); +} + public sealed record CmdletNaming( string VerbsClass, string VerbName, @@ -18,15 +31,14 @@ public sealed record CmdletNaming( 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) + // HttpMethod's own equality is case-insensitive, so "get"/"GET" resolve identically. + private static readonly Dictionary VerbMap = new() { - ["get"] = ("VerbsCommon", "Get"), - ["post"] = ("VerbsCommon", "New"), - ["patch"] = ("VerbsData", "Update"), - ["put"] = ("VerbsCommon", "Set"), - ["delete"] = ("VerbsCommon", "Remove"), + [HttpMethod.Get] = PsVerb.Get, + [HttpMethod.Post] = PsVerb.New, + [HttpMethod.Patch] = PsVerb.Update, + [HttpMethod.Put] = PsVerb.Set, + [HttpMethod.Delete] = PsVerb.Remove, }; public static CmdletNaming Resolve(OperationInfo operation) @@ -39,13 +51,18 @@ public static CmdletNaming Resolve(OperationInfo operation) // 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)); + var noun = GeneratorConstants.NounPrefix + 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"; + // + // The "Command" suffix follows PowerShell's own convention for cmdlet classes — the + // PowerShell codebase names the class behind Get-ChildItem "GetChildItemCommand" — and + // also keeps the generated class name from colliding with the Kiota model type of the + // same noun (a GetMgUserCommand class alongside the User model). + var className = $"{verb.Name}{noun}Command"; var pathParamNames = ExtractPathParamNames(operation.Path); var builderExpression = BuildBuilderExpression(operation.Path, pathParamNames); @@ -53,7 +70,7 @@ public static CmdletNaming Resolve(OperationInfo operation) .Select(raw => new HeaderParam(raw, raw.ToPascalCase('-'))) .ToList(); - return new CmdletNaming(verb.VerbsClass, verb.VerbName, noun, className, pathParamNames, builderExpression, headerParams); + return new CmdletNaming(verb.AttributeClass, verb.Name, noun, className, pathParamNames, builderExpression, headerParams); } // Names one of the two internal cmdlets behind a paired GET dispatcher, e.g. @@ -73,8 +90,8 @@ public static CmdletNaming WithSuffix(CmdletNaming naming, string suffix) // /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. + // An OData cast segment like graph.user becomes AsUser (TryBuildCastSegmentNoun), matching + // Get-MgGroupOwnerAsUser. private static string BuildNounFromPath(string path) { var parts = new List(); @@ -83,14 +100,9 @@ private static string BuildNounFromPath(string path) 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) + if (TryBuildCastSegmentNoun(segment) is { } castNounPart) { - parts.Add("As" + castType.ToFirstCharacterUpperCase()); + parts.Add(castNounPart); continue; } @@ -111,6 +123,27 @@ private static string BuildNounFromPath(string path) return string.Concat(parts); } + // The "As" noun part for an OData cast segment ("microsoft.graph.user", or + // "graph.user" in the KiotaCompat specs), matching published names like + // Get-MgGroupOwnerAsUser; null for a non-cast segment. The cast type name is singularized + // like any other segment rather than assumed to be singular already. + public static string? TryBuildCastSegmentNoun(string segment) + { + ArgumentNullException.ThrowIfNull(segment); + var castType = segment.StartsWith("microsoft.graph.", StringComparison.OrdinalIgnoreCase) + ? segment["microsoft.graph.".Length..] + : segment.StartsWith("graph.", StringComparison.OrdinalIgnoreCase) + ? segment["graph.".Length..] + : null; + return castType is null + ? null + : "As" + Singularizer.SingularizeSegment(castType.ToFirstCharacterUpperCase()); + } + + // The cmdlet parameter names for a path's {parameter} segments, in path order. Each raw + // name is PascalCased on its hyphens: "{user-id}" -> "UserId", "{bookingBusiness-id}" -> + // "BookingBusinessId". These become the cmdlet's mandatory positional parameters and the + // indexer arguments in the builder expression. private static List ExtractPathParamNames(string path) { var names = new List(); @@ -122,8 +155,9 @@ private static List ExtractPathParamNames(string path) 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]. + // The Kiota request-builder chain for a path, returned as C# source text ready to append + // after "client.": one property per fixed segment, one indexer per path parameter, e.g. + // "/users/{user-id}/messages/{message-id}" -> "Users[UserId].Messages[MessageId]". private static string BuildBuilderExpression(string path, List pathParamNames) { var expression = new System.Text.StringBuilder(); @@ -140,7 +174,7 @@ private static string BuildBuilderExpression(string path, List pathParam { if (!first) expression.Append('.'); - expression.Append(ToBuilderMemberName(segment)); + expression.Append(ToCastAwareBuilderMemberName(segment)); } first = false; } @@ -148,15 +182,16 @@ private static string BuildBuilderExpression(string path, List pathParam return expression.ToString(); } - // A fixed path segment becomes one Kiota request-builder member. An OData cast segment - // carries dots ("microsoft.graph.user", or "graph.user" in the KiotaCompat specs); Kiota - // maps it to a single member by upper-casing each dot-separated part and concatenating, so - // "microsoft.graph.user" is the "MicrosoftGraphUser" property, "graph.user" is "GraphUser" - // (verified against a generated Kiota C# client). Emitting the raw segment would leave a - // stray dot ("Graph.user") — invalid C# and not a real builder member. Non-cast segments - // have no dots and are unchanged. NOTE: cast endpoints are not generated end to end yet - // (tracked follow-up), so this keeps the expression a valid identifier chain until then. - private static string ToBuilderMemberName(string segment) => + // A fixed path segment becomes one Kiota request-builder member; the "cast-aware" part is + // what the name is for. An OData cast segment carries dots ("microsoft.graph.user", or + // "graph.user" in the KiotaCompat specs); Kiota maps it to a single member by upper-casing + // each dot-separated part and concatenating, so "microsoft.graph.user" is the + // "MicrosoftGraphUser" property, "graph.user" is "GraphUser" (verified against a generated + // Kiota C# client). Emitting the raw segment would leave a stray dot ("Graph.user") — + // invalid C# and not a real builder member. Non-cast segments have no dots and pass + // through unchanged. NOTE: cast endpoints are not generated end to end yet (tracked + // follow-up), so this keeps the expression a valid identifier chain until then. + private static string ToCastAwareBuilderMemberName(string segment) => segment.Contains('.', StringComparison.Ordinal) ? string.Concat(segment.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(part => part.ToFirstCharacterUpperCase())) : segment.ToFirstCharacterUpperCase(); diff --git a/tools/WrapperGenerator/GeneratorConstants.cs b/tools/WrapperGenerator/GeneratorConstants.cs new file mode 100644 index 0000000000..e6828795e3 --- /dev/null +++ b/tools/WrapperGenerator/GeneratorConstants.cs @@ -0,0 +1,9 @@ +namespace WrapperGenerator; + +// Generator-wide naming constants. The noun prefix is expected to become user-configurable +// (per PR review); until then every consumer reads it from here so that change lands in one +// place instead of a scattered find/replace. +internal static class GeneratorConstants +{ + public const string NounPrefix = "Mg"; +} diff --git a/tools/WrapperGenerator/GeneratorExtensions.cs b/tools/WrapperGenerator/GeneratorExtensions.cs index 6afdad3165..9be10f4d5a 100644 --- a/tools/WrapperGenerator/GeneratorExtensions.cs +++ b/tools/WrapperGenerator/GeneratorExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Microsoft.OpenApi; @@ -7,29 +7,19 @@ namespace WrapperGenerator; // Small string and OpenAPI-schema helpers used across the generator. internal static class GeneratorExtensions { - public static string ToFirstCharacterLowerCase(this string? input) - => string.IsNullOrEmpty(input) ? string.Empty : char.ToLowerInvariant(input[0]) + input[1..]; - public static string ToFirstCharacterUpperCase(this string? input) => string.IsNullOrEmpty(input) ? string.Empty : char.ToUpperInvariant(input[0]) + input[1..]; private static readonly char[] defaultSeparators = ['-']; - public static string ToPascalCase(this string? input, params char[] separators) => ToInternalCamelCase(input, separators, true); - - private static string ToInternalCamelCase(string? input, char[] separators, bool firstCharacterUpperCase = false, bool normalizeFirstCharacter = true) + // Pascal-cases separator-delimited text: "user-id" -> "UserId". + public static string ToPascalCase(this string? input, params char[] separators) { - if (string.IsNullOrEmpty(input)) return string.Empty; - if (separators is null || separators.Length == 0) separators = defaultSeparators; - var chunks = input.Split(separators, StringSplitOptions.RemoveEmptyEntries); - if (chunks.Length == 0) return string.Empty; - return ((normalizeFirstCharacter, firstCharacterUpperCase) switch - { - (false, _) => chunks[0], - (true, true) => chunks[0].ToFirstCharacterUpperCase(), - (true, false) => chunks[0].ToFirstCharacterLowerCase() - }) + - string.Join(string.Empty, chunks.Skip(1).Select(ToFirstCharacterUpperCase)); + if (string.IsNullOrEmpty(input)) + return string.Empty; + if (separators is null || separators.Length == 0) + separators = defaultSeparators; + return string.Concat(input.Split(separators, StringSplitOptions.RemoveEmptyEntries).Select(ToFirstCharacterUpperCase)); } // Resolves the schema name for a $ref: a referenced schema is an OpenApiSchemaReference and diff --git a/tools/WrapperGenerator/NamingOverrides.cs b/tools/WrapperGenerator/NamingOverrides.cs index 803e961a20..db36576a21 100644 --- a/tools/WrapperGenerator/NamingOverrides.cs +++ b/tools/WrapperGenerator/NamingOverrides.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Text.RegularExpressions; namespace WrapperGenerator; @@ -21,21 +22,28 @@ private enum OverrideKind StripNounPrefix, } - private sealed record Entry(OverrideKind Kind, string? Method, string PathPrefix, bool ExactPath, string? Value, string Reason); + private sealed record Entry(OverrideKind Kind, HttpMethod? 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, + new(OverrideKind.SuppressOperation, HttpMethod.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", + new(OverrideKind.ReplaceNoun, HttpMethod.Get, "/users/{}/calendar", ExactPath: true, Value: "UserDefaultCalendar", Reason: "Calendar.md directive renames UserCalendar to UserDefaultCalendar"), + // The SDK ships no cmdlets for the /solutions root singleton itself (Get-MgSolution / + // Update-MgSolution do not exist): src/Bookings/Bookings.md removes every solutionsRoot + // operation with remove-path-by-operation ^solution\.solutionsRoot.*$. Exact-path, all + // methods, so operations on children like /solutions/bookingBusinesses are unaffected. + new(OverrideKind.SuppressOperation, Method: null, "/solutions", ExactPath: true, Value: null, + Reason: "Bookings.md remove-path-by-operation ^solution\\.solutionsRoot.*$"), + // Most nouns under /solutions/ drop the "Solution" prefix (for example // Get-MgBookingBusiness, Get-MgVirtualEventWebinar). BackupRestore is a known // exception where published cmdlets keep the Solution prefix. @@ -52,7 +60,7 @@ private sealed record Entry(OverrideKind Kind, string? Method, string PathPrefix private static string NormalizePath(string pathTemplate) => PathParamRegex().Replace(pathTemplate, "{}").TrimEnd('/').ToLowerInvariant(); - public static bool IsSuppressed(string httpMethod, string pathTemplate) + public static bool IsSuppressed(HttpMethod httpMethod, string pathTemplate) { ArgumentNullException.ThrowIfNull(httpMethod); ArgumentNullException.ThrowIfNull(pathTemplate); @@ -65,7 +73,7 @@ public static bool IsSuppressed(string httpMethod, string pathTemplate) return false; } - public static string ApplyNounOverrides(string httpMethod, string pathTemplate, string noun) + public static string ApplyNounOverrides(HttpMethod httpMethod, string pathTemplate, string noun) { ArgumentNullException.ThrowIfNull(httpMethod); ArgumentNullException.ThrowIfNull(pathTemplate); @@ -94,9 +102,10 @@ public static string ApplyNounOverrides(string httpMethod, string pathTemplate, return noun; } - private static bool Matches(Entry entry, string httpMethod, string normalizedPath) + private static bool Matches(Entry entry, HttpMethod httpMethod, string normalizedPath) { - if (entry.Method is not null && !string.Equals(entry.Method, httpMethod, StringComparison.OrdinalIgnoreCase)) + // HttpMethod's own equality is case-insensitive, so no string comparison is needed. + if (entry.Method is not null && entry.Method != httpMethod) return false; return entry.ExactPath ? string.Equals(normalizedPath, entry.PathPrefix, StringComparison.Ordinal) diff --git a/tools/WrapperGenerator/OperationInfo.cs b/tools/WrapperGenerator/OperationInfo.cs index e8ede2787d..264f26981a 100644 --- a/tools/WrapperGenerator/OperationInfo.cs +++ b/tools/WrapperGenerator/OperationInfo.cs @@ -1,19 +1,14 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Net.Http; namespace WrapperGenerator; +// The slice of one OpenAPI operation that Naming.Resolve consumes — kept to exactly the +// values it reads. Per-operation response and query bookkeeping lives with the generation +// service (GetOperationRecord), not here. public sealed record OperationInfo( - string HttpMethod, + HttpMethod 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. diff --git a/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs b/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs index 3316b54e81..279470cd9a 100644 --- a/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs +++ b/tools/WrapperGenerator/PowerShellWrapperGenerationService.cs @@ -10,10 +10,10 @@ namespace WrapperGenerator; -// 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. +// Emits one PowerShell cmdlet class per selected OpenAPI operation, straight from the +// OpenApiDocument that Program loaded and IncludePathFilter trimmed. There is no CodeDOM, +// refiner, or writer pipeline in between: an operation goes from OpenAPI to C# source text +// in one pass (naming via CmdletNaming, code text via CmdletEmitter). public sealed partial class PowerShellWrapperGenerationService { private readonly OpenApiDocument document; @@ -61,36 +61,43 @@ public async Task GenerateAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + // Operation shapes the emitters cannot produce a valid cmdlet for yet are + // skipped up front instead of emitted malformed: OData $-segments would produce + // broken names (Get-MgBookingBusinesscount) or invalid builder chains + // (client...$value does not compile), and parameterized function segments + // ("getByUserIdAndRole(userId='{userId}',...)") mangle into garbage nouns. + // The README's gap list tracks these shapes as future work. + if (HasUnsupportedPathSegment(pathTemplate)) + { + LogSkippedUnsupportedOperation(httpMethod.Method, pathTemplate, "unsupported OData path segment ($-segment or parameterized function), not generated yet"); + continue; + } + // Skip operations the published SDK deliberately does not ship. NamingOverrides // holds the citation for each one. - if (NamingOverrides.IsSuppressed(httpMethod.Method, pathTemplate)) + if (NamingOverrides.IsSuppressed(httpMethod, 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. // - // The "2XX" response key and "application/json" content type are intentional, - // Graph-scoped assumptions, not a generic OpenAPI reader: the Graph metadata keys - // every success response as "2XX" and describes JSON bodies. This generator only - // targets Graph, so the direct indexers are deliberate. A general reader would - // need to fall back across 200/201/default and negotiate content types. + // Success-response resolution is Graph-scoped on purpose: the Graph metadata + // keys success responses as "2XX" with JSON content, so TryGetSuccessJsonSchema + // checks "2XX" first and falls back across 200/201/default and "+json" content + // types for the few operations that deviate, rather than doing general content + // negotiation the way a generic OpenAPI reader would have to. var responseSchema = httpMethod == HttpMethod.Get ? TryGetSuccessJsonSchema(operation) : 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); + var cmdletNaming = Naming.Resolve(new OperationInfo(httpMethod, pathTemplate, headerParams)); if (httpMethod == HttpMethod.Get && responseSchema is null) { @@ -242,6 +249,15 @@ private async Task WriteCmdletFileAsync(CmdletNaming naming, string source, [LoggerMessage(Level = LogLevel.Warning, Message = "Skipped {Method} {PathTemplate}: {Reason}")] private partial void LogSkippedUnsupportedOperation(string method, string pathTemplate, string reason); + // True when the path contains a segment shape the emitters cannot handle yet: an OData + // $-segment ($count/$value/$ref) or a parameterized function/action call (any segment + // with parentheses, including delta()). OData cast segments (microsoft.graph.user) are + // deliberately NOT excluded here: they emit valid builder chains and the parity gate + // tracks them separately. + private static bool HasUnsupportedPathSegment(string pathTemplate) => + pathTemplate.Split('/', StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.StartsWith('$') || segment.Contains('(')); + // collectionValueSchema is the already-resolved "value" array property from // GetOperationRecord, so nothing is re-walked here. private static bool TryResolveListEntityTypeName(IOpenApiSchema collectionValueSchema, string modelsNamespace, out string entityTypeName) @@ -349,11 +365,6 @@ private static bool TryResolveEntityTypeName(IOpenApiSchema schema, string model return true; } - 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) diff --git a/tools/WrapperGenerator/Program.cs b/tools/WrapperGenerator/Program.cs index 837357fc7c..dfe08a5a09 100644 --- a/tools/WrapperGenerator/Program.cs +++ b/tools/WrapperGenerator/Program.cs @@ -4,7 +4,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; @@ -68,7 +67,7 @@ private static async Task Main(string[] args) IncludePathFilter.Apply(document, includePaths); var config = new GeneratorConfig(ClientNamespaceName: clientNamespace, OutputPath: outputPath); - var service = new PowerShellWrapperGenerationService(document, config, NullLogger.Instance); + var service = new PowerShellWrapperGenerationService(document, config, new StderrLogger()); await service.GenerateAsync(CancellationToken.None).ConfigureAwait(false); // The generation service writes only *.g.cs. Also write a minimal kiota-lock.json recording diff --git a/tools/WrapperGenerator/README.md b/tools/WrapperGenerator/README.md index 963593ef42..aa348a69d0 100644 --- a/tools/WrapperGenerator/README.md +++ b/tools/WrapperGenerator/README.md @@ -56,7 +56,7 @@ A few published names aren't algorithmic — they come from hand-written directi ## The one subtle part: list + item GET become one cmdlet -Graph has two GETs for a resource — the collection (`GET …/messages`) and a single item (`GET …/messages/{message-id}`) — but the published SDK exposes **one** cmdlet, `Get-MgUserMessage`,that does both: no `-MessageId` lists them, a `-MessageId` fetches one. +Graph has two GETs for a resource — the collection (`GET …/messages`) and a single item (`GET …/messages/{message-id}`) — but the published SDK exposes **one** cmdlet, `Get-MgUserMessage`, that does both: no `-MessageId` lists them, a `-MessageId` fetches one. The generator reproduces that. When it finds a list GET and an item GET that share a noun, it emits **three** files: diff --git a/tools/WrapperGenerator/SchemaProperties.cs b/tools/WrapperGenerator/SchemaProperties.cs index f8b15322ce..59269c8c9e 100644 --- a/tools/WrapperGenerator/SchemaProperties.cs +++ b/tools/WrapperGenerator/SchemaProperties.cs @@ -32,11 +32,11 @@ void Walk(IOpenApiSchema s) if (IsPlainScalar(propSchema)) { - result.Add(new CmdletProperty(name, name.ToFirstCharacterUpperCase(), MapPsType(propSchema.Type!.Value), IsArray: false)); + result.Add(new CmdletProperty(name, name.ToFirstCharacterUpperCase(), MapPsType(propSchema), 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)); + result.Add(new CmdletProperty(name, name.ToFirstCharacterUpperCase(), MapPsType(items) + "[]", IsArray: true)); } } } @@ -67,11 +67,16 @@ public static bool HasPasswordProfile(IOpenApiSchema schema) _ => false, }; - private static string MapPsType(JsonSchemaType openApiType) => (openApiType & ~JsonSchemaType.Null) switch + // Numeric mapping follows the OpenAPI format so values survive the round trip: an int64 + // property must not truncate to int (overflow above ~2.1 billion) and a number property + // must not lose its fraction to integer truncation. + private static string MapPsType(IOpenApiSchema schema) => (schema.Type & ~JsonSchemaType.Null) switch { JsonSchemaType.String => "string", JsonSchemaType.Boolean => "bool", - JsonSchemaType.Integer or JsonSchemaType.Number => "int", + JsonSchemaType.Integer when string.Equals(schema.Format, "int64", StringComparison.OrdinalIgnoreCase) => "long", + JsonSchemaType.Integer => "int", + JsonSchemaType.Number => "double", _ => "string", }; diff --git a/tools/WrapperGenerator/StderrLogger.cs b/tools/WrapperGenerator/StderrLogger.cs new file mode 100644 index 0000000000..4f863fa9f0 --- /dev/null +++ b/tools/WrapperGenerator/StderrLogger.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace WrapperGenerator; + +// Minimal stderr logger for CLI runs, so the generation service's skip diagnostics (for +// example unsupported OData path shapes) are actually visible without taking a +// console-logging package dependency. Warning and above only: the per-file "Wrote ..." +// chatter stays quiet, and Program prints its own one-line summary. +internal sealed class StderrLogger : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (IsEnabled(logLevel)) + Console.Error.WriteLine($"[{logLevel}] {formatter(state, exception)}"); + } +} From 8b49c5e9af03c8f614c77f860d496b893d540739 Mon Sep 17 00:00:00 2001 From: Joywambui-maina Date: Mon, 3 Aug 2026 12:43:35 -0700 Subject: [PATCH 4/4] chore(wrapper-generator): drop OutputType Exe from the test csproj Requested in PR #3682 review. xunit.v3 auto-generates an entry point only when building as an exe, and dotnet test runs through the VSTest adapter either way, so the setting was never needed. All 78 tests still discover and pass without it. --- tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj b/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj index a3634244bc..e007e302e7 100644 --- a/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj +++ b/tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj @@ -2,7 +2,6 @@ net10.0 - Exe disable enable false