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 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 + + + + + + + + + +