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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion tools/Build-WrapperModule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,19 @@ function Build-OneModule {
}

$wrapperOut = & dotnet run --project $generatorProject -- -d $spec -o $cmdletsDir -n $clientNs 2>&1
if ($LASTEXITCODE -ne 0) { $result.FailedAt = 'wrapper-generator'; $result.Error = ($wrapperOut | Select-Object -Last 3) -join ' | '; return $result }
if ($LASTEXITCODE -ne 0) {
# Skip warnings precede the failure; the exception message is what identifies it.
$result.FailedAt = 'wrapper-generator'
$lines = @($wrapperOut | ForEach-Object { "$_" })
$exception = $lines | Where-Object { $_ -match 'Unhandled exception|Exception:' } | Select-Object -First 1
$exceptionIndex = if ($exception) { $lines.IndexOf($exception) } else { -1 }
Comment on lines +149 to +151
$result.Error = if ($exceptionIndex -ge 0) {
($lines[$exceptionIndex..([Math]::Min($exceptionIndex + 5, $lines.Count - 1))] | Where-Object { $_ -notmatch '^\s+at ' }) -join ' | '
} else {
($lines | Where-Object { $_ -notmatch '^\s+at ' } | Select-Object -First 6) -join ' | '
}
return $result
}

# Generated artifact, machine-local by design (absolute reference into this clone).
$csprojPath = Join-Path $srcDir "$moduleName.csproj"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,83 @@ public async Task GenerateAsync_SkipsUnsupportedODataPathSegments_DoesNotEmitMal
Assert.DoesNotContain(files, f => f != "Shared.g.cs");
}

// Two operations resolving to the same cmdlet file must fail generation loudly,
// identifying both operations — never silently overwrite. The real shipped collision
// (/sites/{id}/sites) is renamed via NamingOverrides, so a synthetic self-referential
// path keeps the guard itself exercised.
[Fact]
public async Task GenerateAsync_FailsLoudlyWhenTwoCmdletsResolveToTheSameFile()
{
var document = new OpenApiDocument
{
Paths = new OpenApiPaths(),
Components = new OpenApiComponents
{
Schemas = new Dictionary<string, IOpenApiSchema>
{
["microsoft.graph.widget"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["displayName"] = new OpenApiSchema { Type = JsonSchemaType.String },
},
},
},
},
};

static OpenApiOperation ItemGet(OpenApiDocument doc) => new()
{
Responses = new OpenApiResponses
{
["200"] = new OpenApiResponse
{
Content = new Dictionary<string, IOpenApiMediaType>
{
["application/json"] = new OpenApiMediaType
{
Schema = new OpenApiSchemaReference("microsoft.graph.widget", doc),
},
},
},
},
};

// /widgets/{id} and /widgets/{id}/widgets/{id2} both singularize to the noun Widget;
// with two same-noun item GETs nothing merges, and both emit GetMgWidget.g.cs.
document.Paths["/widgets/{widget-id}"] = new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation> { [HttpMethod.Get] = ItemGet(document) },
};
document.Paths["/widgets/{widget-id}/widgets/{widget-id1}"] = new OpenApiPathItem
{
Operations = new Dictionary<HttpMethod, OpenApiOperation> { [HttpMethod.Get] = ItemGet(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);

var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.GenerateAsync(CancellationToken.None));

Assert.Contains("GetMgWidget.g.cs", ex.Message);
Assert.Contains("collision", ex.Message);
// Both colliding cmdlets are named Get-MgWidget, so only their builder expressions
// prove the message identifies both operations.
Assert.Contains("[Widgets[WidgetId]]", ex.Message);
Assert.Contains("Widgets[WidgetId].Widgets[WidgetId1]", ex.Message);
}
finally
{
if (Directory.Exists(outputDir))
Directory.Delete(outputDir, recursive: true);
}
}

private static OpenApiDocument BuildDocument(HttpMethod method, string path, OpenApiOperation operation)
{
return new OpenApiDocument
Expand Down
66 changes: 61 additions & 5 deletions tools/WrapperGenerator.Tests/NamingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ private static CmdletNaming Resolve(string method, string path) =>
[InlineData("GET", "/solutions/bookingBusinesses/{bookingBusiness-id}", "Get", "MgBookingBusiness")]
[InlineData("PATCH", "/solutions/bookingBusinesses/{bookingBusiness-id}", "Update", "MgBookingBusiness")]
[InlineData("GET", "/users/{user-id}/calendar", "Get", "MgUserDefaultCalendar")]
// self-referential sites rename to SubSite instead of colliding with the parent noun
[InlineData("GET", "/sites/{site-id}/sites", "Get", "MgSubSite")]
[InlineData("GET", "/sites/{site-id}/sites/{site-id1}", "Get", "MgSubSite")]
[InlineData("GET", "/groups/{group-id}/sites/{site-id}/sites", "Get", "MgGroupSubSite")]
[InlineData("GET", "/groups/{group-id}/sites/{site-id}/sites/{site-id1}", "Get", "MgGroupSubSite")]
// default-singleton renames (issue #3704 oracle sweep)
[InlineData("GET", "/users/{user-id}/drive", "Get", "MgUserDefaultDrive")]
[InlineData("GET", "/groups/{group-id}/drive", "Get", "MgGroupDefaultDrive")]
[InlineData("GET", "/sites/{site-id}/drive", "Get", "MgSiteDefaultDrive")]
[InlineData("GET", "/groups/{group-id}/sites/{site-id}/drive", "Get", "MgGroupSiteDefaultDrive")]
[InlineData("GET", "/users/{user-id}/calendar/events", "Get", "MgUserDefaultCalendarEvent")]
// nested-collection GET renamed by the Groups.md directive (subject $1ByGroup)
[InlineData("GET", "/groups/{group-id}/groupLifecyclePolicies", "Get", "MgGroupLifecyclePolicyByGroup")]
// boundary word-overlap collapse (Get-MgDomainNameReference)
[InlineData("GET", "/domains/{domain-id}/domainNameReferences", "Get", "MgDomainNameReference")]
// adjacent-duplicate collapse (Get-MgUserOnenoteSectionGroup... family)
Expand Down Expand Up @@ -177,6 +190,34 @@ public void SuppressesOperationsThePublishedSdkOmits()
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/solutions"));
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Patch, "/solutions"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/solutions/bookingBusinesses/{bookingBusiness-id}"));

// The /photos collection ships no distinct cmdlet; only the /photo singleton does.
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/users/{user-id}/photos"));
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/users/{user-id}/photos/{userProfilePhoto-id}"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/users/{user-id}/photo"));

// Suffix-matched suppressions apply under any root; siblings stay generated
// (issue #3704: Info-wrapper navs ship nothing, their siblings ship).
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/chats/{chat-id}/pinnedMessages/{pinnedChatMessageInfo-id}/message"));
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/teams/{team-id}/channels/{channel-id}/sharedWithTeams/{id}/team"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/teams/{team-id}/channels/{channel-id}/sharedWithTeams/{id}/allowedMembers"));

// Exact-matched suppressions cover only the named node; descendants with no entry of
// their own stay generated (Security nested navs, issue #3704).
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/security/threatIntelligence/hosts/{host-id}/components"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/security/threatIntelligence/hosts/{host-id}/components/$count"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/security/threatIntelligence/hosts/{host-id}/passiveDns"));

// termStore trees are stitched: /termStores/{id} descendants ship nothing (the 402
// descendant command rows come from the /termStore singleton trees), and the singleton
// root GET ships no distinct cmdlet (Get-MgSiteTermStore serves both /termStore and
// /termStores; GET generates from the collection side only).
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/sites/{site-id}/termStores/{store-id}"));
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/sites/{site-id}/termStores/{store-id}/sets/{set-id}"));
Assert.True(NamingOverrides.IsSuppressed(HttpMethod.Get, "/sites/{site-id}/termStore"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Patch, "/sites/{site-id}/termStore"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/sites/{site-id}/termStores"));
Assert.False(NamingOverrides.IsSuppressed(HttpMethod.Get, "/sites/{site-id}/termStore/sets/{set-id}"));
}

[Theory]
Expand Down Expand Up @@ -228,14 +269,29 @@ public void GetWithNoStructuralPartnerStaysStandalone()
}

[Fact]
public void AmbiguousSameNounDoesNotMerge()
public void CastListItemPairMergesLikeAPlainPair()
{
// The published SDK ships one Get-MgGroupOwnerAsUser covering both the cast on the
// collection and the cast on the item; without pairing, both emit the same file.
var list = Resolve("GET", "/groups/{group-id}/owners/graph.user");
var item = Resolve("GET", "/groups/{group-id}/owners/{directoryObject-id}/graph.user");
Assert.Equal(list.Noun, item.Noun);
Assert.True(Naming.IsListItemPair(list, item));

// Different cast types never pair.
var otherCast = Resolve("GET", "/groups/{group-id}/owners/{directoryObject-id}/graph.servicePrincipal");
Assert.False(Naming.IsListItemPair(list, otherCast));
}

[Fact]
public void SelfReferentialSitesRenameInsteadOfCollidingWithParent()
{
// 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.
// Without the SubSite rename, /sites/{id}/sites singularizes to the parent's own noun
// and its cmdlet file would collide with Get-MgSite's. The renamed nouns are pinned in
// ResolvesPublishedSdkNames; this pins that the pair no longer merges or collides.
var list = Resolve("GET", "/sites/{site-id}/sites");
var item = Resolve("GET", "/sites/{site-id}");
Assert.Equal(list.Noun, item.Noun);
Assert.NotEqual(list.Noun, item.Noun);
Assert.False(Naming.IsListItemPair(list, item));
}
}
52 changes: 45 additions & 7 deletions tools/WrapperGenerator/CmdletNaming.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,16 +197,54 @@ private static string ToCastAwareBuilderMemberName(string segment) =>
: 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.
// dispatcher: the item's path must extend the list's path by exactly one id, either
// trailing (Users[UserId].Messages -> Users[UserId].Messages[MessageId]) or inserted
// before a shared trailing OData cast (Owners.GraphUser -> Owners[Id].GraphUser). Callers
// group by noun first, so this only decides the structural fit; a same-noun item that does
// not extend the list 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);
if (item.PathParamNames.Count != list.PathParamNames.Count + 1
|| !item.PathParamNames.Take(list.PathParamNames.Count).SequenceEqual(list.PathParamNames))
return false;

if (item.BuilderExpression.StartsWith(list.BuilderExpression + "[", StringComparison.Ordinal))
return true;

// OData cast pair: the id inserts BEFORE the trailing cast member, not at the end
// (owners/graph.user vs owners/{id}/graph.user builds Owners.GraphUser vs
// Owners[Id].GraphUser). The published SDK ships these as one cmdlet, same as a
// plain list/item pair; without this the two emit identical file names and collide.
var listCast = TrailingCastMember(list.BuilderExpression);
var itemCast = TrailingCastMember(item.BuilderExpression);
if (listCast is null || !string.Equals(listCast, itemCast, StringComparison.Ordinal))
return false;

var listStem = list.BuilderExpression[..^(listCast.Length + 1)];
var itemStem = item.BuilderExpression[..^(itemCast.Length + 1)];
if (!itemStem.StartsWith(listStem + "[", StringComparison.Ordinal) || !itemStem.EndsWith("]", StringComparison.Ordinal))
return false;
var indexer = itemStem[(listStem.Length + 1)..^1];
return indexer.Length > 0 && !indexer.Contains('[') && !indexer.Contains('.');
}

// The kiota builder member for a trailing OData cast segment (GraphUser from
// "graph.user", MicrosoftGraphUser from "microsoft.graph.user"); null when the
// expression does not end in a cast.
private static string? TrailingCastMember(string builderExpression)
{
var lastDot = builderExpression.LastIndexOf('.');
if (lastDot < 0)
return null;
var member = builderExpression[(lastDot + 1)..];
if (member.Contains('['))
return null;
if (member.StartsWith("MicrosoftGraph", StringComparison.Ordinal) && member.Length > 14 && char.IsUpper(member[14]))
return member;
if (member.StartsWith("Graph", StringComparison.Ordinal) && member.Length > 5 && char.IsUpper(member[5]))
return member;
return null;
}
}
Loading
Loading