From b69828a56421313b1a0b659ae6ca554d51546997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:54:46 +0000 Subject: [PATCH 1/9] Initial plan From 9bc8c4681a0f92712fccee68de97e44441db147b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:08:15 +0000 Subject: [PATCH 2/9] Add support for parent field in @tagMetadata decorator - Updated TagMetadata model to include parent field - Modified openapi.ts to convert parent to x-parent for 3.0/3.1 - Added comprehensive tests for all OpenAPI versions - Tests passing for parent field in 3.2, x-parent in 3.0/3.1 Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- .../generated-defs/TypeSpec.OpenAPI.ts | 1 + packages/openapi/lib/decorators.tsp | 3 + packages/openapi3/src/openapi.ts | 5 ++ packages/openapi3/test/tagmetadata.test.ts | 82 ++++++++++++++++++- .../libraries/openapi/reference/data-types.md | 11 +-- 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index b04fb4dec2b..1b1773a5125 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -21,6 +21,7 @@ export interface TagMetadata { readonly [key: string]: unknown; readonly description?: string; readonly externalDocs?: ExternalDocs; + readonly parent?: string; } export interface Contact { diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index c1ddfefd7ed..bff9f60493a 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -124,6 +124,9 @@ model TagMetadata { /** An external Docs information of the API. */ externalDocs?: ExternalDocs; + /** The name of a tag that this tag is nested under. Only supported in OpenAPI 3.2. For 3.0 and 3.1, this will be converted to `x-parent`. */ + parent?: string; + /** Attach some custom data, The extension key must start with `x-`. */ ...Record; } diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index ebe16d5643b..b36ee558908 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -442,6 +442,11 @@ function createOAPIEmitter( if (metadata) { for (const [name, tag] of Object.entries(metadata)) { const tagData: OpenAPI3Tag = { name: name, ...tag }; + // For OpenAPI 3.0 and 3.1, convert 'parent' to 'x-parent' extension + if (specVersion !== "3.2.0" && "parent" in tagData) { + (tagData as any)["x-parent"] = tagData.parent; + delete (tagData as any).parent; + } tagsMetadata[name] = tagData; } } diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 9f2ef92929b..c5e057e2b26 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,6 +1,6 @@ import { deepStrictEqual } from "assert"; -import { it } from "vitest"; -import { supportedVersions, worksFor } from "./works-for.js"; +import { describe, it } from "vitest"; +import { OpenAPISpecHelpers, supportedVersions, worksFor } from "./works-for.js"; worksFor(supportedVersions, ({ openApiFor }) => { const testCases: [string, string, string, any][] = [ @@ -102,3 +102,81 @@ worksFor(supportedVersions, ({ openApiFor }) => { deepStrictEqual(res.tags, expected); }); }); + +// Test for parent field - version specific behavior +describe("tag metadata with parent field", () => { + it("OpenAPI 3.2 should emit parent field as-is", async () => { + const res = await OpenAPISpecHelpers["3.2.0"].openApiFor( + ` + @service + @tagMetadata("ParentTag", #{description: "Parent tag"}) + @tagMetadata("ChildTag", #{description: "Child tag", parent: "ParentTag"}) + namespace PetStore { + @tag("ChildTag") op test(): string; + } + `, + ); + + deepStrictEqual(res.tags, [ + { + name: "ChildTag", + description: "Child tag", + parent: "ParentTag", + }, + { + name: "ParentTag", + description: "Parent tag", + }, + ]); + }); + + it("OpenAPI 3.1 should convert parent to x-parent", async () => { + const res = await OpenAPISpecHelpers["3.1.0"].openApiFor( + ` + @service + @tagMetadata("ParentTag", #{description: "Parent tag"}) + @tagMetadata("ChildTag", #{description: "Child tag", parent: "ParentTag"}) + namespace PetStore { + @tag("ChildTag") op test(): string; + } + `, + ); + + deepStrictEqual(res.tags, [ + { + name: "ChildTag", + description: "Child tag", + "x-parent": "ParentTag", + }, + { + name: "ParentTag", + description: "Parent tag", + }, + ]); + }); + + it("OpenAPI 3.0 should convert parent to x-parent", async () => { + const res = await OpenAPISpecHelpers["3.0.0"].openApiFor( + ` + @service + @tagMetadata("ParentTag", #{description: "Parent tag"}) + @tagMetadata("ChildTag", #{description: "Child tag", parent: "ParentTag"}) + namespace PetStore { + @tag("ChildTag") op test(): string; + } + `, + ); + + deepStrictEqual(res.tags, [ + { + name: "ChildTag", + description: "Child tag", + "x-parent": "ParentTag", + }, + { + name: "ParentTag", + description: "Parent tag", + }, + ]); + }); +}); diff --git a/website/src/content/docs/docs/libraries/openapi/reference/data-types.md b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md index cb03c688b29..b6e89310e0c 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md @@ -85,8 +85,9 @@ model TypeSpec.OpenAPI.TagMetadata #### Properties -| Name | Type | Description | -| ------------- | --------------------------------------------------------------- | ---------------------------------------- | -| description? | `string` | A description of the API. | -| externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | An external Docs information of the API. | -| | `unknown` | Additional properties | +| Name | Type | Description | +| ------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| description? | `string` | A description of the API. | +| externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | An external Docs information of the API. | +| parent? | `string` | The name of a tag that this tag is nested under. Only supported in OpenAPI 3.2. For 3.0 and 3.1, this will be converted to `x-parent`. | +| | `unknown` | Additional properties | From 4aaaef7095449fdd52e6c133d7d9781e59e8ce6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:18:30 +0000 Subject: [PATCH 3/9] Update documentation with parent field example - Added example of nested tags using parent field - Rebuilt packages to update generated types Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/openapi/generated-defs/TypeSpec.OpenAPI.ts | 1 + packages/openapi/lib/decorators.tsp | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 1b1773a5125..48b894737b3 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -134,6 +134,7 @@ export type InfoDecorator = ( * ```typespec * @service() * @tagMetadata("Tag Name", #{description: "Tag description", externalDocs: #{url: "https://example.com", description: "More info.", `x-custom`: "string"}, `x-custom`: "string"}) + * @tagMetadata("Child Tag", #{description: "Child tag description", parent: "Tag Name"}) * namespace PetStore {} * ``` */ diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index bff9f60493a..e00bf2d1126 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -152,6 +152,7 @@ model ExternalDocs { * ```typespec * @service() * @tagMetadata("Tag Name", #{description: "Tag description", externalDocs: #{url: "https://example.com", description: "More info.", `x-custom`: "string"}, `x-custom`: "string"}) + * @tagMetadata("Child Tag", #{description: "Child tag description", parent: "Tag Name"}) * namespace PetStore {} * ``` */ From d9e4bfd0b1d26d8506d2d8a68e83077b8079aa75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:39:53 +0000 Subject: [PATCH 4/9] Remove unnecessary type assertions in tag parent conversion - Changed from using 'as any' to a more type-safe approach - Use tag.parent from source TagMetadata to check parent existence - Only use type assertion on delete operation with explicit type Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/openapi3/src/openapi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index b36ee558908..367c4f9e6ef 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -443,9 +443,9 @@ function createOAPIEmitter( for (const [name, tag] of Object.entries(metadata)) { const tagData: OpenAPI3Tag = { name: name, ...tag }; // For OpenAPI 3.0 and 3.1, convert 'parent' to 'x-parent' extension - if (specVersion !== "3.2.0" && "parent" in tagData) { - (tagData as any)["x-parent"] = tagData.parent; - delete (tagData as any).parent; + if (specVersion !== "3.2.0" && tag.parent) { + tagData["x-parent"] = tag.parent; + delete (tagData as { parent?: string }).parent; } tagsMetadata[name] = tagData; } From c831ac798612a6b38c9c37daf662961b05a8108b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:51:46 +0000 Subject: [PATCH 5/9] Add generated documentation files for parent field - Updated README.md with parent field example - Updated decorators.md with parent field example - Generated files from full build Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/openapi/README.md | 1 + .../content/docs/docs/libraries/openapi/reference/decorators.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 26f9b3e42d6..7e848c6c622 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -176,6 +176,7 @@ Specify OpenAPI additional information. `x-custom`: "string", } ) +@tagMetadata("Child Tag", #{ description: "Child tag description", parent: "Tag Name" }) namespace PetStore { } diff --git a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md index cd223411b0d..23f9a426b8c 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md @@ -165,6 +165,7 @@ Specify OpenAPI additional information. `x-custom`: "string", } ) +@tagMetadata("Child Tag", #{ description: "Child tag description", parent: "Tag Name" }) namespace PetStore { } From 2cd4f778576c8d3f84e2b4ceffdf9f4c92c083aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:39:03 +0000 Subject: [PATCH 6/9] Merge main branch and resolve conflicts - Integrated parent field conversion logic into refactored resolveDocumentTags function - Updated test imports to match main branch changes - Preserved parent field tests for all OpenAPI versions Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- ...ix-tag-metadata-scope-2026-1-4-17-54-29.md | 8 + ...RootSlashClientOption-2026-1-4-15-58-33.md | 7 + ...l-tag-openapi-versions-2026-2-4-18-5-32.md | 7 + .../SKILL.md | 75 ++++++ packages/astro-utils/src/css/fluentui.css | 65 +++++ .../src/Providers/ClientProvider.cs | 85 +++++- .../Providers/CollectionResultDefinition.cs | 2 +- .../src/Providers/RestClientProvider.cs | 70 +++-- .../ClientProviders/ClientProviderTests.cs | 103 ++++++++ .../RestClientProviderTests.cs | 250 +++++++++++++++++- .../TestClient.cs | 15 ++ .../TestClient.cs | 15 ++ .../src/TypeFactory.cs | 6 + .../src/Utilities/CSharpTypeExtensions.cs | 24 ++ .../ModelProviders/ModelCustomizationTests.cs | 63 +++++ .../MockInputModel.cs | 10 + .../Bar.cs | 11 + .../MockInputModel.cs | 10 + .../generator/docs/backward-compatibility.md | 116 ++++++++ .../http-client-python/emitter/src/http.ts | 5 +- packages/http-client-python/package-lock.json | 14 +- packages/http-client-python/package.json | 4 +- packages/openapi3/src/lib.ts | 1 - packages/openapi3/src/openapi.ts | 53 ++-- packages/openapi3/test/tagmetadata.test.ts | 52 +++- packages/openapi3/test/test-host.ts | 31 +++ packages/openapi3/test/works-for.ts | 4 + 27 files changed, 1043 insertions(+), 63 deletions(-) create mode 100644 .chronus/changes/fix-tag-metadata-scope-2026-1-4-17-54-29.md create mode 100644 .chronus/changes/python-addRootSlashClientOption-2026-1-4-15-58-33.md create mode 100644 .chronus/changes/remove-internal-tag-openapi-versions-2026-2-4-18-5-32.md create mode 100644 .github/skills/http-client-python-bump-and-release/SKILL.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterCasingPreservedFromLastContractView/TestClient.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterUsesConstantWhenNotInLastContractView/TestClient.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingGeneratedType/MockInputModel.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/Bar.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/MockInputModel.cs diff --git a/.chronus/changes/fix-tag-metadata-scope-2026-1-4-17-54-29.md b/.chronus/changes/fix-tag-metadata-scope-2026-1-4-17-54-29.md new file mode 100644 index 00000000000..04563c1bb41 --- /dev/null +++ b/.chronus/changes/fix-tag-metadata-scope-2026-1-4-17-54-29.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fix: tag metadata not scopped to the service it was defined on diff --git a/.chronus/changes/python-addRootSlashClientOption-2026-1-4-15-58-33.md b/.chronus/changes/python-addRootSlashClientOption-2026-1-4-15-58-33.md new file mode 100644 index 00000000000..fc963add83c --- /dev/null +++ b/.chronus/changes/python-addRootSlashClientOption-2026-1-4-15-58-33.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Add support for `@clientOption("includeRootSlash")` to control stripping of the slash after the root url \ No newline at end of file diff --git a/.chronus/changes/remove-internal-tag-openapi-versions-2026-2-4-18-5-32.md b/.chronus/changes/remove-internal-tag-openapi-versions-2026-2-4-18-5-32.md new file mode 100644 index 00000000000..de49a95eb9d --- /dev/null +++ b/.chronus/changes/remove-internal-tag-openapi-versions-2026-2-4-18-5-32.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Expose `openapi-versions` emitter option now that both 3.1.0 and 3.2.0 are implemented. \ No newline at end of file diff --git a/.github/skills/http-client-python-bump-and-release/SKILL.md b/.github/skills/http-client-python-bump-and-release/SKILL.md new file mode 100644 index 00000000000..03436535b82 --- /dev/null +++ b/.github/skills/http-client-python-bump-and-release/SKILL.md @@ -0,0 +1,75 @@ +--- +name: http-client-python-bump-and-release +description: Create a PR to bump dependencies or release a new version of the http-client-python package. Use when the user wants to bump TypeSpec/Azure-tools dependencies, update peer dependencies, or release a new version of the Python HTTP client. +--- + +# HTTP Client Python Bump and Release + +Create a new PR to bump dependencies and release a new version of the http-client-python package. + +> **Note:** `{REPO}` refers to the root folder of the `microsoft/typespec` repository. + +## Prerequisites + +Before starting, verify that `npm-check-updates` is available: + +```bash +npx npm-check-updates --version +``` + +If the command fails or prompts for installation, install it globally: + +```bash +npm install -g npm-check-updates +``` + +## Workflow + +1. Navigate to the package directory: + + ```bash + cd {REPO}/packages/http-client-python + ``` + +2. Reset and sync with main: + + ```bash + git reset HEAD && git checkout . && git checkout origin/main && git pull origin main + ``` + +3. Create release branch (use current date in MM-DD format): + + ```bash + git checkout -b publish/python-release-{MM-DD} + ``` + +4. Update dependencies: + + ```bash + npx npm-check-updates -u --filter @typespec/*,@azure-tools/* --packageFile package.json + ``` + +5. Update `peerDependencies` in package.json: + - If format is `">=0.a.b <1.0.0"`: Update only the `0.a.b` portion, keep the range format unchanged + - If format is `"^1.a.b"`: Update to the latest version + +6. Run version change script: + + ```bash + npm run change:version + ``` + +7. Build and commit: + + ```bash + npm install && npm run build && git add -u && git commit -m "bump version" + ``` + +8. Push and create PR: + + ```bash + cd {REPO} + git push origin HEAD + ``` + +9. Create PR with title `[python] release new version` and no description. diff --git a/packages/astro-utils/src/css/fluentui.css b/packages/astro-utils/src/css/fluentui.css index 9c9afb93098..bc16ee06536 100644 --- a/packages/astro-utils/src/css/fluentui.css +++ b/packages/astro-utils/src/css/fluentui.css @@ -111,3 +111,68 @@ html { -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } + +@font-face { + font-family: "Segoe UI"; + src: + local("Segoe UI Light"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff2) + format("woff2"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.woff) + format("woff"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/light/latest.ttf) + format("truetype"); + font-weight: 100; +} + +@font-face { + font-family: "Segoe UI"; + src: + local("Segoe UI Semilight"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff2) + format("woff2"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.woff) + format("woff"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semilight/latest.ttf) + format("truetype"); + font-weight: 200; +} + +@font-face { + font-family: "Segoe UI"; + src: + local("Segoe UI"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff2) + format("woff2"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.woff) + format("woff"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/normal/latest.ttf) + format("truetype"); + font-weight: 400; +} + +@font-face { + font-family: "Segoe UI"; + src: + local("Segoe UI Semibold"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff2) + format("woff2"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.woff) + format("woff"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/semibold/latest.ttf) + format("truetype"); + font-weight: 600; +} + +@font-face { + font-family: "Segoe UI"; + src: + local("Segoe UI Bold"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff2) + format("woff2"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.woff) + format("woff"), + url(https://c.s-microsoft.com/static/fonts/segoe-ui/west-european/bold/latest.ttf) + format("truetype"); + font-weight: 700; +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs index 0d5372e01ff..ae19e6f6f77 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ClientProvider.cs @@ -734,9 +734,11 @@ private MethodBodyStatement[] BuildPrimaryConstructorBody(IReadOnlyList Args) ConvertUriTemplateToFormattableString( + string uriTemplate, + IReadOnlyList parameters) + { + // Build a lookup for parameters by name (case-insensitive) + var paramsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var param in parameters) + { + paramsByName[param.Name] = param; + } + + // Also add the endpoint parameter explicitly (it may have a different name) + if (!paramsByName.ContainsKey(_endpointParameter.Name)) + { + paramsByName[_endpointParameter.Name] = _endpointParameter; + } + + // Also add fields from _additionalClientFields + foreach (var field in _additionalClientFields.Value) + { + // Field names are like "_apiVersion", parameter names are like "ApiVersion" + var paramName = field.Name.TrimStart('_'); + if (!paramsByName.ContainsKey(paramName)) + { + paramsByName[paramName] = field.AsParameter; + } + } + + var args = new List(); + var result = new System.Text.StringBuilder(); + var templateSpan = uriTemplate.AsSpan(); + + while (templateSpan.Length > 0) + { + var openBrace = templateSpan.IndexOf('{'); + if (openBrace < 0) + { + // No more placeholders, append the rest + result.Append(templateSpan); + break; + } + + // Append literal part before the placeholder + result.Append(templateSpan.Slice(0, openBrace)); + templateSpan = templateSpan.Slice(openBrace + 1); + + var closeBrace = templateSpan.IndexOf('}'); + if (closeBrace < 0) + { + // Malformed template, append remaining as-is + result.Append('{'); + result.Append(templateSpan); + break; + } + + var paramName = templateSpan.Slice(0, closeBrace).ToString(); + templateSpan = templateSpan.Slice(closeBrace + 1); + + // Find the corresponding parameter or field + if (paramsByName.TryGetValue(paramName, out var param)) + { + result.Append('{'); + result.Append(args.Count); + result.Append('}'); + args.Add(param.Field ?? (ValueExpression)param); + } + else + { + // Parameter not found - this is a configuration error + throw new InvalidOperationException( + $"URI template placeholder '{{{paramName}}}' in '{uriTemplate}' could not be resolved. " + + $"Available parameters: {string.Join(", ", paramsByName.Keys)}"); + } + } + + return (result.ToString(), args); + } + private IReadOnlyList GetSubClients() { var subClients = new List(_inputClient.Children.Count); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/CollectionResultDefinition.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/CollectionResultDefinition.cs index 9d631107d0c..b896e6e537e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/CollectionResultDefinition.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/CollectionResultDefinition.cs @@ -94,7 +94,7 @@ public CollectionResultDefinition(ClientProvider client, InputPagingServiceMetho NextTokenField = field; } - if (field.AsParameter.Name == pageSize) + if (string.Equals(field.AsParameter.Name, pageSize, StringComparison.OrdinalIgnoreCase)) { PageSizeField = field; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs index 6f9878dd1b6..a86611a7aef 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/RestClientProvider.cs @@ -25,6 +25,7 @@ public class RestClientProvider : TypeProvider private const string RepeatabilityFirstSentHeader = "Repeatability-First-Sent"; private const string TopParameterName = "top"; private const string MaxCountParameterName = "maxCount"; + private const string MaxPageSizeParameterName = "maxPageSize"; private static readonly Dictionary _knownSpecialHeaderParams = new(StringComparer.OrdinalIgnoreCase) { @@ -194,7 +195,7 @@ private MethodBodyStatements BuildMessage( var operation = serviceMethod.Operation; var classifier = GetClassifier(operation); - var paramMap = new Dictionary(signature.Parameters.ToDictionary(p => p.Name)); + var paramMap = new Dictionary(signature.Parameters.ToDictionary(p => p.Name), StringComparer.OrdinalIgnoreCase); foreach (var param in ClientProvider.ClientParameters) { paramMap[param.Name] = param; @@ -703,7 +704,7 @@ private void AddUriSegments( /* when the parameter is in operation.uri, it is client parameter * It is not operation parameter and not in inputParamHash list. */ - var isClientParameter = ClientProvider.ClientParameters.Any(p => p.Name == paramName); + var isClientParameter = ClientProvider.ClientParameters.Any(p => string.Equals(p.Name, paramName, StringComparison.OrdinalIgnoreCase)); CSharpType? type; SerializationFormat? serializationFormat; ValueExpression? valueExpression; @@ -714,25 +715,18 @@ private void AddUriSegments( } else { - if (isClientParameter) + inputParam = inputParamMap[paramName]; + if (inputParam is InputPathParameter || inputParam is InputEndpointParameter) { - GetParamInfo(paramMap[paramName], out type, out serializationFormat, out valueExpression); + GetParamInfo(paramMap, operation, inputParam, out type, out serializationFormat, out valueExpression); + if (valueExpression == null) + { + break; + } } else { - inputParam = inputParamMap[paramName]; - if (inputParam is InputPathParameter || inputParam is InputEndpointParameter) - { - GetParamInfo(paramMap, operation, inputParam, out type, out serializationFormat, out valueExpression); - if (valueExpression == null) - { - break; - } - } - else - { - throw new InvalidOperationException($"The location of parameter {inputParam.Name} should be path or uri"); - } + throw new InvalidOperationException($"The location of parameter {inputParam.Name} should be path or uri"); } } string? format = serializationFormat?.ToFormatSpecifier(); @@ -870,13 +864,36 @@ private static bool TryGetSpecialHeaderParam(InputParameter inputParameter, [Not return false; } - private static string? GetPageSizeParameterName(InputPagingServiceMethod? pagingServiceMethod) + private static string? GetPageSizeParameterName(InputPagingServiceMethod? pagingServiceMethod) { return pagingServiceMethod?.PagingMetadata?.PageSizeParameterSegments?.Count > 0 ? pagingServiceMethod.PagingMetadata.PageSizeParameterSegments.Last() : null; } + private static string GetCorrectedPageSizeName(string originalName, ClientProvider client) + { + // Check if parameter exists in LastContractView for backward compatibility + var existingParam = client.LastContractView?.Methods + ?.SelectMany(method => method.Signature.Parameters) + .FirstOrDefault(parameter => string.Equals(parameter.Name, originalName, StringComparison.OrdinalIgnoreCase)) + ?.Name; + + if (existingParam != null) + { + return existingParam; + } + + // Normalize badly-cased "maxpagesize" variants to Camel Case + if (string.Equals(originalName, MaxPageSizeParameterName, StringComparison.OrdinalIgnoreCase)) + { + return MaxPageSizeParameterName; + } + + // Keep original name for all other cases + return originalName; + } + private static bool ShouldUpdateReinjectedParameter(InputParameter inputParameter, InputPagingServiceMethod? pagingServiceMethod) { // Check if this is an API version parameter @@ -943,6 +960,14 @@ internal static List GetMethodParameters( // For convenience methods, use the service method parameters var inputParameters = methodType is ScmMethodKind.Convenience ? serviceMethod.Parameters : operation.Parameters; + var pageSizeParameterName = GetPageSizeParameterName(serviceMethod as InputPagingServiceMethod); + + string? correctedPageSizeName = null; + if (pageSizeParameterName != null) + { + correctedPageSizeName = GetCorrectedPageSizeName(pageSizeParameterName, client); + } + ModelProvider? spreadSource = null; if (methodType == ScmMethodKind.Convenience) { @@ -995,6 +1020,15 @@ internal static List GetMethodParameters( inputParam.Update(name: MaxCountParameterName); } + // For paging operations, ensure page size parameter uses the correct casing + if (correctedPageSizeName != null && string.Equals(inputParam.Name, pageSizeParameterName, StringComparison.OrdinalIgnoreCase)) + { + if (!string.Equals(inputParam.Name, correctedPageSizeName, StringComparison.Ordinal)) + { + inputParam.Update(name: correctedPageSizeName); + } + } + ParameterProvider? parameter = ScmCodeModelGenerator.Instance.TypeFactory.CreateParameter(inputParam)?.ToPublicInputParameter(); if (parameter is null) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs index 6068edcf87c..c774e76edf3 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/ClientProviders/ClientProviderTests.cs @@ -3427,5 +3427,108 @@ public void GetApiVersionFieldForService_MultiService_CaseInsensitiveMatch() Assert.IsNotNull(fieldUpperCase); Assert.AreEqual("_serviceAApiVersion", fieldUpperCase!.Name); } + + [TestCase("{endpoint}")] + [TestCase("{Endpoint}")] + [TestCase("{ENDPOINT}")] + public void ConvertUriTemplate_CaseInsensitiveEndpointLookup(string serverTemplate) + { + // Tests that the parameter lookup in ConvertUriTemplateToFormattableString is case-insensitive + MockHelpers.LoadMockGenerator(); + var client = InputFactory.Client( + TestClientName, + parameters: [InputFactory.EndpointParameter( + "endpoint", + InputPrimitiveType.String, + isRequired: true, + scope: InputParameterScope.Client, + serverUrlTemplate: serverTemplate, + isEndpoint: true)]); + var clientProvider = new ClientProvider(client); + var constructor = clientProvider.Constructors.FirstOrDefault( + c => c.Signature.Initializer == null && c.Signature?.Modifiers == MethodSignatureModifiers.Public); + + Assert.IsNotNull(constructor); + // Should not throw and should contain the Uri assignment + var bodyText = constructor!.BodyStatements!.ToDisplayString(); + Assert.IsTrue(bodyText.Contains("_endpoint = new global::System.Uri($\"")); + } + + [Test] + public void ConvertUriTemplate_CaseInsensitivePathParameterLookup() + { + // Tests template with mixed case placeholders like "{Endpoint}/services/{ApiVersion}" + MockHelpers.LoadMockGenerator(); + + var serverTemplate = "{Endpoint}/{ApiVersion}"; + var client = InputFactory.Client( + TestClientName, + methods: [InputFactory.BasicServiceMethod("Test", InputFactory.Operation("test", uri: serverTemplate))], + parameters: [ + InputFactory.EndpointParameter( + "endpoint", // lowercase parameter name + InputPrimitiveType.String, + isRequired: true, + scope: InputParameterScope.Client, + serverUrlTemplate: serverTemplate, + isEndpoint: true), + InputFactory.PathParameter( + "apiVersion", // lowercase parameter name + InputPrimitiveType.String, + isRequired: true, + scope: InputParameterScope.Client) + ]); + var clientProvider = new ClientProvider(client); + var constructor = clientProvider.Constructors.FirstOrDefault( + c => c.Signature.Initializer == null && c.Signature?.Modifiers == MethodSignatureModifiers.Public); + + Assert.IsNotNull(constructor); + // Should not throw - case-insensitive lookup should find parameters + var bodyText = constructor!.BodyStatements!.ToDisplayString(); + Assert.IsNotNull(bodyText); + // Verify that the Uri is built according to the server template with case-insensitive parameter matching + Assert.IsTrue(bodyText.Contains("$\"{endpoint}/{_apiVersion}\"")); + } + + [Test] + public void ConvertUriTemplate_WithMultiplePlaceholders() + { + // Tests template with multiple placeholders: "{endpoint}/{apiVersion}/services/{subscriptionId}" + MockHelpers.LoadMockGenerator(); + + var serverTemplate = "{endpoint}/{apiVersion}/services/{subscriptionId}"; + var client = InputFactory.Client( + TestClientName, + methods: [InputFactory.BasicServiceMethod("Test", InputFactory.Operation("test", uri: serverTemplate))], + parameters: [ + InputFactory.EndpointParameter( + "endpoint", + InputPrimitiveType.String, + isRequired: true, + scope: InputParameterScope.Client, + serverUrlTemplate: serverTemplate, + isEndpoint: true), + InputFactory.PathParameter( + "apiVersion", + InputPrimitiveType.String, + isRequired: true, + scope: InputParameterScope.Client), + InputFactory.PathParameter( + "subscriptionId", + InputPrimitiveType.String, + isRequired: true, + scope: InputParameterScope.Client) + ]); + var clientProvider = new ClientProvider(client); + var constructor = clientProvider.Constructors.FirstOrDefault( + c => c.Signature.Initializer == null && c.Signature?.Modifiers == MethodSignatureModifiers.Public); + + Assert.IsNotNull(constructor); + var bodyText = constructor!.BodyStatements!.ToDisplayString(); + Assert.IsNotNull(bodyText); + // Verify that the Uri is built according to the server template + Assert.IsTrue(bodyText.Contains("$\"{endpoint}/{_apiVersion}/services/{_subscriptionId}\"")); + } } } + diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs index ba39f3a8488..fabd789e61d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/RestClientProviderTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.TypeSpec.Generator.ClientModel.Providers; using Microsoft.TypeSpec.Generator.Input; @@ -1393,6 +1394,14 @@ private static IEnumerable ValidateApiVersionPathParameterTestCase scope: InputParameterScope.Client, isApiVersion: true); + InputMethodParameter pascalCaseApiVersionParameter = InputFactory.MethodParameter( + "ApiVersion", + InputPrimitiveType.String, + location: InputRequestLocation.Uri, + isRequired: true, + scope: InputParameterScope.Client, + isApiVersion: true); + InputMethodParameter enumApiVersionParameter = InputFactory.MethodParameter( "apiVersion", InputFactory.StringEnum( @@ -1420,7 +1429,7 @@ private static IEnumerable ValidateApiVersionPathParameterTestCase uri: "{endpoint}/{apiVersion}")) ], parameters: [endpointParameter, stringApiVersionParameter])); - + yield return new TestCaseData( InputFactory.Client( "TestClient", @@ -1433,6 +1442,45 @@ private static IEnumerable ValidateApiVersionPathParameterTestCase uri: "{endpoint}/{apiVersion}")) ], parameters: [endpointParameter, enumApiVersionParameter])); + + yield return new TestCaseData( + InputFactory.Client( + "TestClient", + methods: + [ + InputFactory.BasicServiceMethod( + "TestServiceMethod", + InputFactory.Operation( + "TestOperation", + uri: "{endpoint}/{ApiVersion}")) + ], + parameters: [endpointParameter, stringApiVersionParameter])); + + yield return new TestCaseData( + InputFactory.Client( + "TestClient", + methods: + [ + InputFactory.BasicServiceMethod( + "TestServiceMethod", + InputFactory.Operation( + "TestOperation", + uri: "{endpoint}/{apiVersion}")) + ], + parameters: [endpointParameter, pascalCaseApiVersionParameter])); + + yield return new TestCaseData( + InputFactory.Client( + "TestClient", + methods: + [ + InputFactory.BasicServiceMethod( + "TestServiceMethod", + InputFactory.Operation( + "TestOperation", + uri: "{endpoint}/{ApiVersion}")) + ], + parameters: [endpointParameter, pascalCaseApiVersionParameter])); } [Test] @@ -1584,5 +1632,205 @@ public void ContentTypeHeaderNotWrappedInNullCheckWhenContentIsRequired() Assert.IsFalse(hasIfWrappedContentType, $"Content-Type should NOT be wrapped in an if statement for required content, but found:\n{statementsString}"); } + + [Test] + public async Task PageSizeParameterCasingPreservedFromLastContractView() + { + var pageSizeParam = InputFactory.QueryParameter("maxSizepaging", InputPrimitiveType.Int32, + isRequired: false, serializedName: "maxSizepaging"); + + List parameters = + [ + pageSizeParam, + ]; + + List methodParameters = + [ + InputFactory.MethodParameter("maxSizepaging", InputPrimitiveType.Int32, isRequired: false, + location: InputRequestLocation.Query, serializedName: "maxSizepaging"), + ]; + + var inputModel = InputFactory.Model("Item", properties: + [ + InputFactory.Property("id", InputPrimitiveType.String, isRequired: true), + ]); + + var pagingMetadata = new InputPagingServiceMetadata( + ["value"], + new InputNextLink(null, ["nextLink"], InputResponseLocation.Body, []), + null, + ["maxSizepaging"]); + + var response = InputFactory.OperationResponse( + [200], + InputFactory.Model( + "PagedItems", + properties: [ + InputFactory.Property("value", InputFactory.Array(inputModel)), + InputFactory.Property("nextLink", InputPrimitiveType.Url) + ])); + + var operation = InputFactory.Operation("GetItems", responses: [response], parameters: parameters); + var inputServiceMethod = InputFactory.PagingServiceMethod( + "GetItems", + operation, + pagingMetadata: pagingMetadata, + parameters: methodParameters); + + var client = InputFactory.Client("TestClient", methods: [inputServiceMethod]); + + var generator = await MockHelpers.LoadMockGeneratorAsync( + clients: () => [client], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var clientProvider = generator.Object.OutputLibrary.TypeProviders.OfType().FirstOrDefault(); + Assert.IsNotNull(clientProvider); + Assert.IsNotNull(clientProvider!.LastContractView); + + var methodParams = RestClientProvider.GetMethodParameters(inputServiceMethod, ScmMethodKind.Convenience, clientProvider!); + + var pageSizeParameter = methodParams.FirstOrDefault(p => + string.Equals(p.Name, "maxsizepaging", StringComparison.Ordinal) || + string.Equals(p.Name, "maxSizepaging", StringComparison.Ordinal)); + + Assert.IsNotNull(pageSizeParameter, "Page size parameter should be present in method parameters"); + Assert.AreEqual("maxsizepaging", pageSizeParameter!.Name, + "Parameter name should be 'maxsizepaging' (from LastContractView), not 'maxSizepaging' (from input)"); + } + + [Test] + public async Task PageSizeParameterUsesConstantWhenNotInLastContractView() + { + var pageSizeParam = InputFactory.QueryParameter("maxpagesize", InputPrimitiveType.Int32, + isRequired: false, serializedName: "maxpagesize"); + + List parameters = + [ + pageSizeParam, + ]; + + List methodParameters = + [ + InputFactory.MethodParameter("maxpagesize", InputPrimitiveType.Int32, isRequired: false, + location: InputRequestLocation.Query, serializedName: "maxpagesize"), + ]; + + var inputModel = InputFactory.Model("Item", properties: + [ + InputFactory.Property("id", InputPrimitiveType.String, isRequired: true), + ]); + + var pagingMetadata = new InputPagingServiceMetadata( + ["value"], + new InputNextLink(null, ["nextLink"], InputResponseLocation.Body, []), + null, + ["maxpagesize"]); + + var response = InputFactory.OperationResponse( + [200], + InputFactory.Model( + "PagedItems", + properties: [ + InputFactory.Property("value", InputFactory.Array(inputModel)), + InputFactory.Property("nextLink", InputPrimitiveType.Url) + ])); + + var operation = InputFactory.Operation("GetItems", responses: [response], parameters: parameters); + var inputServiceMethod = InputFactory.PagingServiceMethod( + "GetItems", + operation, + pagingMetadata: pagingMetadata, + parameters: methodParameters); + + var client = InputFactory.Client("TestClient", methods: [inputServiceMethod]); + + var generator = await MockHelpers.LoadMockGeneratorAsync( + clients: () => [client], + lastContractCompilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var clientProvider = generator.Object.OutputLibrary.TypeProviders.OfType().FirstOrDefault(); + Assert.IsNotNull(clientProvider); + Assert.IsNotNull(clientProvider!.LastContractView); + + var methodParams = RestClientProvider.GetMethodParameters(inputServiceMethod, ScmMethodKind.Convenience, clientProvider!); + + var pageSizeParameter = methodParams.FirstOrDefault(p => + string.Equals(p.Name, "maxPageSize", StringComparison.Ordinal) || + string.Equals(p.Name, "maxpagesize", StringComparison.Ordinal)); + + Assert.IsNotNull(pageSizeParameter, "Page size parameter should be present in method parameters"); + Assert.AreEqual("maxPageSize", pageSizeParameter!.Name, + "Parameter name should be 'maxPageSize' (from constant), not 'maxpagesize' (from input)"); + } + + [Test] + public void PageSizeParameterSerializedNameUsedInCreateRequestMethod() + { + var pageSizeParam = InputFactory.QueryParameter("maxpagesize", InputPrimitiveType.Int32, + isRequired: false, serializedName: "maxpagesize"); + + List parameters = [pageSizeParam]; + + List methodParameters = + [ + InputFactory.MethodParameter("maxpagesize", InputPrimitiveType.Int32, isRequired: false, + location: InputRequestLocation.Query, serializedName: "maxpagesize"), + ]; + + var inputModel = InputFactory.Model("Item", properties: + [ + InputFactory.Property("id", InputPrimitiveType.String, isRequired: true), + ]); + + var pagingMetadata = new InputPagingServiceMetadata( + ["value"], + new InputNextLink(null, ["nextLink"], InputResponseLocation.Body, []), + null, + ["maxpagesize"]); + + var response = InputFactory.OperationResponse( + [200], + InputFactory.Model( + "PagedItems", + properties: [ + InputFactory.Property("value", InputFactory.Array(inputModel)), + InputFactory.Property("nextLink", InputPrimitiveType.Url) + ])); + + var operation = InputFactory.Operation("GetItems", responses: [response], parameters: parameters); + var inputServiceMethod = InputFactory.PagingServiceMethod( + "GetItems", + operation, + pagingMetadata: pagingMetadata, + parameters: methodParameters); + + var client = InputFactory.Client("TestClient", methods: [inputServiceMethod]); + var clientProvider = new ClientProvider(client); + var restClientProvider = clientProvider.RestClient; + + // Get the CreateRequest method + var createRequestMethod = restClientProvider.Methods.FirstOrDefault(m => + m.Signature.Name == "CreateGetItemsRequest"); + + Assert.IsNotNull(createRequestMethod, "CreateGetItemsRequest method should exist"); + + // Verify the method parameter uses the corrected name + var methodParam = createRequestMethod!.Signature.Parameters.FirstOrDefault(p => + string.Equals(p.Name, "maxPageSize", StringComparison.Ordinal)); + + Assert.IsNotNull(methodParam, "Method parameter should use corrected name 'maxPageSize'"); + Assert.AreEqual("maxPageSize", methodParam!.Name, + "Method signature should use 'maxPageSize' (corrected from 'maxpagesize')"); + + var writer = new TypeProviderWriter(restClientProvider); + var file = writer.Write(); + + // The generated code should use the parameter name "maxPageSize" and append it to the query string + // The serialized name "maxpagesize" should be used in uri.AppendQuery("maxpagesize", maxPageSize, true) + Assert.IsTrue(file.Content.Contains("maxPageSize"), + "Generated code should use corrected parameter name 'maxPageSize'"); + Assert.IsTrue(file.Content.Contains("uri.AppendQuery(\"maxpagesize\""), + "Generated code should use the serialized name 'maxpagesize' in the query string"); + } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterCasingPreservedFromLastContractView/TestClient.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterCasingPreservedFromLastContractView/TestClient.cs new file mode 100644 index 00000000000..eb748ec70fc --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterCasingPreservedFromLastContractView/TestClient.cs @@ -0,0 +1,15 @@ +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Threading.Tasks; + +namespace Sample +{ + public partial class TestClient + { + // This represents the previous contract with maxsizepaging parameter + public virtual Task GetItemsAsync(int? maxsizepaging, CancellationToken cancellationToken = default) { return null; } + public virtual ClientResult GetItems(int? maxsizepaging, CancellationToken cancellationToken = default) { return null; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterUsesConstantWhenNotInLastContractView/TestClient.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterUsesConstantWhenNotInLastContractView/TestClient.cs new file mode 100644 index 00000000000..2206fa7fbd6 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/RestClientProviders/TestData/RestClientProviderTests/PageSizeParameterUsesConstantWhenNotInLastContractView/TestClient.cs @@ -0,0 +1,15 @@ +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Threading.Tasks; + +namespace Sample +{ + public partial class TestClient + { + // This represents a previous contract WITHOUT maxPageSize parameter + public virtual Task GetItemsAsync(string someOtherParam, CancellationToken cancellationToken = default) { return null; } + public virtual ClientResult GetItems(string someOtherParam, CancellationToken cancellationToken = default) { return null; } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs index bd6a4d8c6dc..1b4f9a4871a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/TypeFactory.cs @@ -23,6 +23,10 @@ public class TypeFactory private Dictionary InputTypeToModelProvider { get; } = []; public IDictionary CSharpTypeMap { get; } = new Dictionary(CSharpType.IgnoreNullableComparer); + + // Maps C# type names to TypeProviders for efficient lookup when resolving types by name + internal IDictionary TypeProvidersByName { get; } = new Dictionary(); + private Dictionary EnumCache { get; } = []; private Dictionary TypeCache { get; } = []; @@ -197,6 +201,7 @@ protected internal TypeFactory() if (modelProvider != null) { CSharpTypeMap[modelProvider.Type] = modelProvider; + TypeProvidersByName[modelProvider.Type.Name] = modelProvider; } return modelProvider; } @@ -255,6 +260,7 @@ protected internal TypeFactory() if (enumProvider != null) { CSharpTypeMap[enumProvider.Type] = enumProvider; + TypeProvidersByName[enumProvider.Type.Name] = enumProvider; } return enumProvider; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs index f7a7f22db7e..ae2f51a6d14 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Utilities/CSharpTypeExtensions.cs @@ -47,6 +47,18 @@ private static CSharpType EnsureNamespace(InputProperty? specProperty, CSharpTyp inputType = GetInputEnumType(specProperty?.Type); } + // If we still don't have an input type (e.g., custom property without spec property), + // try to look up the type by name in the TypeFactory + if (inputType == null) + { + // Try to resolve by looking up the CSharp type directly + var resolvedType = TryFindCSharpTypeByName(type.Name); + if (resolvedType != null) + { + return type.IsNullable ? resolvedType.WithNullable(true) : resolvedType; + } + } + if (inputType == null) { return type; @@ -64,6 +76,18 @@ private static CSharpType EnsureNamespace(InputProperty? specProperty, CSharpTyp return type; } + private static CSharpType? TryFindCSharpTypeByName(string typeName) + { + // Look up type provider by name using the efficient name-based dictionary + // This handles cases where the type is renamed using CodeGenType attribute + if (CodeModelGenerator.Instance.TypeFactory.TypeProvidersByName.TryGetValue(typeName, out var typeProvider)) + { + return typeProvider.Type; + } + + return null; + } + private static bool IsCustomizedEnumProperty( InputProperty? inputProperty, CSharpType customType, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs index ae09d2e9a53..2b7df86bf1f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelCustomizationTests.cs @@ -1475,6 +1475,69 @@ public async Task CanCustomizeBaseModelWithSpecBase() Assert.AreEqual("CustomBaseProp", modelProvider.BaseTypeProvider.Properties[0].Name); } + [Test] + public async Task CanAddPropertyReferencingGeneratedType() + { + // Create Bar model that will be referenced by the custom property + var barModel = InputFactory.Model("Bar", properties: [ + InputFactory.Property("b", InputPrimitiveType.String) + ], usage: InputModelTypeUsage.Input); + + var inputModel = InputFactory.Model("mockInputModel", properties: [ + InputFactory.Property("Prop1", InputPrimitiveType.String) + ], usage: InputModelTypeUsage.Input); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel, barModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + AssertCommon(modelTypeProvider, "Sample.Models", "MockInputModel"); + + // Validate that the custom property Bars exists in the canonical view + var barsProperty = modelTypeProvider.CanonicalView.Properties.FirstOrDefault(p => p.Name == "Bars"); + Assert.IsNotNull(barsProperty, "Bars property should exist in canonical view"); + + // Validate that the Bars property has IList type with proper namespace + Assert.IsTrue(barsProperty!.Type.IsList); + var elementType = barsProperty.Type.ElementType; + Assert.AreEqual("Bar", elementType.Name); + Assert.AreEqual("Sample.Models", elementType.Namespace, "Bar type should have proper namespace"); + Assert.IsFalse(string.IsNullOrEmpty(elementType.Namespace), "Element type namespace should not be empty"); + } + + [Test] + public async Task CanAddPropertyReferencingRenamedGeneratedType() + { + // Create Bar model that will be renamed to RenamedBar via CodeGenType + var barModel = InputFactory.Model("Bar", properties: [ + InputFactory.Property("b", InputPrimitiveType.String) + ], usage: InputModelTypeUsage.Input); + + var inputModel = InputFactory.Model("mockInputModel", properties: [ + InputFactory.Property("Prop1", InputPrimitiveType.String) + ], usage: InputModelTypeUsage.Input); + + var mockGenerator = await MockHelpers.LoadMockGeneratorAsync( + inputModelTypes: [inputModel, barModel], + compilation: async () => await Helpers.GetCompilationFromDirectoryAsync()); + + var modelTypeProvider = mockGenerator.Object.OutputLibrary.TypeProviders.Single(t => t.Name == "MockInputModel"); + AssertCommon(modelTypeProvider, "Sample.Models", "MockInputModel"); + + // Validate that the custom property RenamedBars exists in the canonical view + var renamedBarsProperty = modelTypeProvider.CanonicalView.Properties.FirstOrDefault(p => p.Name == "RenamedBars"); + Assert.IsNotNull(renamedBarsProperty, "RenamedBars property should exist in canonical view"); + + // Validate that the RenamedBars property has IList type with proper namespace + // Even though Bar is the TypeSpec name, the C# code uses RenamedBar + Assert.IsTrue(renamedBarsProperty!.Type.IsList); + var elementType = renamedBarsProperty.Type.ElementType; + Assert.AreEqual("RenamedBar", elementType.Name); + Assert.AreEqual("Sample.Models", elementType.Namespace, "RenamedBar type should have proper namespace"); + Assert.IsFalse(string.IsNullOrEmpty(elementType.Namespace), "Element type namespace should not be empty"); + } + private class NameSpaceVisitor : LibraryVisitor { protected override TypeProvider? VisitType(TypeProvider type) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingGeneratedType/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingGeneratedType/MockInputModel.cs new file mode 100644 index 00000000000..ff0ffe6bcd3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingGeneratedType/MockInputModel.cs @@ -0,0 +1,10 @@ +#nullable disable + +using System.Collections.Generic; + +namespace Sample.Models; + +internal partial class MockInputModel +{ + public IList Bars { get; } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/Bar.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/Bar.cs new file mode 100644 index 00000000000..d5b5e7ed4d7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/Bar.cs @@ -0,0 +1,11 @@ +#nullable disable + +using SampleTypeSpec; +using Microsoft.TypeSpec.Generator.Customizations; + +namespace Sample.Models; + +[CodeGenType("Bar")] +internal partial class RenamedBar +{ +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/MockInputModel.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/MockInputModel.cs new file mode 100644 index 00000000000..76d32498477 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/TestData/ModelCustomizationTests/CanAddPropertyReferencingRenamedGeneratedType/MockInputModel.cs @@ -0,0 +1,10 @@ +#nullable disable + +using System.Collections.Generic; + +namespace Sample.Models; + +internal partial class MockInputModel +{ + public IList RenamedBars { get; } +} diff --git a/packages/http-client-csharp/generator/docs/backward-compatibility.md b/packages/http-client-csharp/generator/docs/backward-compatibility.md index 10a5ebd15b1..e24d2e29f7e 100644 --- a/packages/http-client-csharp/generator/docs/backward-compatibility.md +++ b/packages/http-client-csharp/generator/docs/backward-compatibility.md @@ -11,6 +11,7 @@ - [API Version Enum](#api-version-enum) - [Non-abstract Base Models](#non-abstract-base-models) - [Model Constructors](#model-constructors) + - [Parameter Name Casing](#parameter-name-casing) ## Overview @@ -359,3 +360,118 @@ public abstract partial class SearchIndexerDataIdentity - The constructor must have matching parameters (same count, types, and names) - The modifier is changed from `private protected` to `public` - No additional constructors are generated; only the accessibility is adjusted + +### Parameter Name Casing + +The generator maintains backward compatibility for parameter names to ensure that existing code continues to compile when parameter name casing is corrected or standardized. + +#### Scenario: Page Size Parameter Casing Correction + +**Description:** When a paging parameter name has incorrect casing in the TypeSpec (e.g., `maxpagesize` instead of `maxPageSize`), the generator handles it in two ways: + +1. **If the parameter exists in LastContractView**: The generator uses the exact casing from the previous version to maintain backward compatibility +2. **If the parameter does NOT exist in LastContractView**: The generator normalizes common badly-cased variants to proper camelCase (e.g., `maxpagesize` → `maxPageSize`) + +This commonly occurs when: + +- TypeSpec defines a paging parameter with non-standard casing (e.g., all lowercase) +- The generator needs to maintain API consistency while respecting the wire format +- New paging operations need standardized parameter naming + +**Example:** + +**Case 1: Parameter exists in LastContractView - badly-cased is preserved (backward compatibility)** + +Previous version had badly-cased parameter name: + +```csharp +public virtual AsyncPageable GetItemsAsync(int? maxpagesize = null, CancellationToken cancellationToken = default) +{ + HttpMessage CreateRequest() + { + var message = pipeline.CreateMessage(); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RequestUriBuilder(); + uri.Reset(endpoint); + uri.AppendPath("/items", false); + if (maxpagesize != null) + { + uri.AppendQuery("maxpagesize", maxpagesize.Value, true); // Serialized name from spec + } + // ... + } + // ... +} +``` + +Current TypeSpec still defines parameter with bad casing: + +```typespec +@query maxpagesize?: int32; // Lowercase in spec +``` + +**Generated Compatibility Result:** + +The generator detects the parameter in LastContractView and preserves its exact badly-cased name to maintain backward compatibility: + +```csharp +public virtual AsyncPageable GetItemsAsync(int? maxpagesize = null, CancellationToken cancellationToken = default) +{ + HttpMessage CreateRequest() + { + var message = pipeline.CreateMessage(); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RequestUriBuilder(); + uri.Reset(endpoint); + uri.AppendPath("/items", false); + if (maxpagesize != null) + { + uri.AppendQuery("maxpagesize", maxpagesize.Value, true); // Still badly-cased for backward compatibility + } + // ... + } + // ... +} +``` + +**Case 2: Parameter does NOT exist in LastContractView - badly-cased is normalized** + +New paging operation with badly-cased parameter (no previous version): + +```typespec +@query maxpagesize?: int32; // Lowercase in spec +``` + +**Generated Result:** + +The generator normalizes the parameter name to proper camelCase since there's no previous version to maintain compatibility with: + +```csharp +public virtual AsyncPageable GetItemsAsync(int? maxPageSize = null, CancellationToken cancellationToken = default) +{ + HttpMessage CreateRequest() + { + var message = pipeline.CreateMessage(); + var request = message.Request; + request.Method = RequestMethod.Get; + var uri = new RequestUriBuilder(); + uri.Reset(endpoint); + uri.AppendPath("/items", false); + if (maxPageSize != null) + { + uri.AppendQuery("maxpagesize", maxPageSize.Value, true); // Serialized name still uses spec's casing + } + // ... + } + // ... +} +``` + +**Key Points:** + +- **Case 1 (Backward compatibility)**: If the parameter exists in LastContractView, its exact casing is preserved - even if badly-cased +- **Case 2 (Normalization)**: If the parameter does NOT exist in LastContractView, badly-cased variants are normalized to proper camelCase +- The HTTP query parameter always uses the original serialized name from the spec (e.g., `maxpagesize`) +- Existing client code continues to compile without changes diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index e87e8b1f113..99184365d16 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -1,6 +1,7 @@ import { NoTarget } from "@typespec/compiler"; import { + getClientOptions, getHttpOperationParameter, SdkBasicServiceMethod, SdkBodyParameter, @@ -378,8 +379,10 @@ function emitHttpOperation( for (const exception of operation.exceptions) { exceptions.push(emitHttpResponse(context, exception.statusCodes, exception, undefined, true)!); } + const includeRootSlash = getClientOptions(rootClient, "includeRootSlash") !== false; + const result = { - url: operation.path, + url: includeRootSlash ? operation.path : operation.path.replace(/^\//, ""), method: operation.verb.toUpperCase(), parameters: emitHttpParameters(context, rootClient, operation, method, serviceApiVersions), bodyParameter: emitHttpBodyParameter(context, operation.bodyParam, serviceApiVersions), diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index ac5e9d4a5f4..63736d18e3a 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -1,12 +1,12 @@ { "name": "@typespec/http-client-python", - "version": "0.26.2", + "version": "0.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@typespec/http-client-python", - "version": "0.26.2", + "version": "0.26.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -22,7 +22,7 @@ "@azure-tools/typespec-azure-core": "~0.64.0", "@azure-tools/typespec-azure-resource-manager": "~0.64.1", "@azure-tools/typespec-azure-rulesets": "~0.64.0", - "@azure-tools/typespec-client-generator-core": "~0.64.5", + "@azure-tools/typespec-client-generator-core": "~0.64.6", "@types/js-yaml": "~4.0.5", "@types/node": "~24.1.0", "@types/semver": "7.5.8", @@ -53,7 +53,7 @@ "@azure-tools/typespec-azure-core": ">=0.64.0 <1.0.0", "@azure-tools/typespec-azure-resource-manager": ">=0.64.1 <1.0.0", "@azure-tools/typespec-azure-rulesets": ">=0.64.0 <1.0.0", - "@azure-tools/typespec-client-generator-core": ">=0.64.5 <1.0.0", + "@azure-tools/typespec-client-generator-core": ">=0.64.6 <1.0.0", "@typespec/compiler": "^1.8.0", "@typespec/events": ">=0.78.0 <1.0.0", "@typespec/http": "^1.8.0", @@ -167,9 +167,9 @@ } }, "node_modules/@azure-tools/typespec-client-generator-core": { - "version": "0.64.5", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.64.5.tgz", - "integrity": "sha512-RaATxsnc9ztdMPoIZ2SuyH97dIGY0BWGKcJBf0hBY+8J3de9o+QH796NA9OsiW+8J9ycCEooDbh/rkAspvA4xA==", + "version": "0.64.6", + "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.64.6.tgz", + "integrity": "sha512-S0OH5UmIltjPdj/rdMD8RBpAQWpFP+0jjXLZSi2ARCZkhzi6++E1fEsqLLNDW7oP0CDq3RYQgpuWyCLZVtVf/A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index 68d9893b858..16b706d4326 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -58,7 +58,7 @@ "@azure-tools/typespec-azure-core": ">=0.64.0 <1.0.0", "@azure-tools/typespec-azure-resource-manager": ">=0.64.1 <1.0.0", "@azure-tools/typespec-azure-rulesets": ">=0.64.0 <1.0.0", - "@azure-tools/typespec-client-generator-core": ">=0.64.5 <1.0.0", + "@azure-tools/typespec-client-generator-core": ">=0.64.6 <1.0.0", "@typespec/compiler": "^1.8.0", "@typespec/http": "^1.8.0", "@typespec/openapi": "^1.8.0", @@ -81,7 +81,7 @@ "@azure-tools/typespec-azure-core": "~0.64.0", "@azure-tools/typespec-azure-resource-manager": "~0.64.1", "@azure-tools/typespec-azure-rulesets": "~0.64.0", - "@azure-tools/typespec-client-generator-core": "~0.64.5", + "@azure-tools/typespec-client-generator-core": "~0.64.6", "@azure-tools/azure-http-specs": "0.1.0-alpha.36", "@typespec/compiler": "^1.8.0", "@typespec/http": "^1.8.0", diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 234d1bee514..7d096319ba0 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -45,7 +45,6 @@ export interface OpenAPI3EmitterOptions { * will be created inside a directory matching each specification version. * * @default ["3.0.0"] - * @internal */ "openapi-versions"?: OpenAPIVersion[]; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 367c4f9e6ef..75edd64f4f7 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -116,6 +116,7 @@ import { OpenAPI3Tag, OpenAPI3VersionedServiceRecord, OpenAPISchema3_1, + OpenAPITag3_2, Refable, SupportedOpenAPIDocuments, } from "./types.js"; @@ -300,10 +301,7 @@ function createOAPIEmitter( let paramModels: Set; // De-dupe the per-endpoint tags that will be added into the #/tags - let tags: Set; - - // The per-endpoint tags that will be added into the #/tags - const tagsMetadata: { [name: string]: OpenAPI3Tag } = {}; + let tagsUsedInOperations: Set; const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace @@ -435,21 +433,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); - tags = new Set(); - - // Get Tags Metadata - const metadata = getTagsMetadata(program, service.type); - if (metadata) { - for (const [name, tag] of Object.entries(metadata)) { - const tagData: OpenAPI3Tag = { name: name, ...tag }; - // For OpenAPI 3.0 and 3.1, convert 'parent' to 'x-parent' extension - if (specVersion !== "3.2.0" && tag.parent) { - tagData["x-parent"] = tag.parent; - delete (tagData as { parent?: string }).parent; - } - tagsMetadata[name] = tagData; - } - } + tagsUsedInOperations = new Set(); } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -744,7 +728,7 @@ function createOAPIEmitter( } emitParameters(); emitSchemas(service.type); - emitTags(); + root.tags = resolveDocumentTags(service); // Clean up empty entries if (root.components) { @@ -842,7 +826,7 @@ function createOAPIEmitter( } for (const tag of opTags) { // Add to root tags if not already there - tags.add(tag); + tagsUsedInOperations.add(tag); } } } @@ -906,7 +890,7 @@ function createOAPIEmitter( oai3Operation.tags = currentTags; for (const tag of currentTags) { // Add to root tags if not already there - tags.add(tag); + tagsUsedInOperations.add(tag); } } @@ -1786,17 +1770,28 @@ function createOAPIEmitter( } } - function emitTags() { - // emit Tag from op - for (const tag of tags) { - if (!tagsMetadata[tag]) { - root.tags!.push({ name: tag }); + /** Resolve tag information to be inserted at the root of the document */ + function resolveDocumentTags(service: Service): OpenAPI3Tag[] | OpenAPITag3_2[] { + const metadata = getTagsMetadata(program, service.type); + + const tags: OpenAPI3Tag[] | OpenAPITag3_2[] = []; + for (const tag of tagsUsedInOperations) { + if (!metadata?.[tag]) { + tags.push({ name: tag }); } } - for (const key in tagsMetadata) { - root.tags!.push(tagsMetadata[key]); + for (const [name, tag] of Object.entries(metadata || {})) { + const tagData: OpenAPI3Tag = { name: name, ...tag }; + // For OpenAPI 3.0 and 3.1, convert 'parent' to 'x-parent' extension + if (specVersion !== "3.2.0" && tag.parent) { + tagData["x-parent"] = tag.parent; + delete (tagData as { parent?: string }).parent; + } + tags.push(tagData); } + + return tags; } function getSchemaForType(type: Type, visibility: Visibility): OpenAPI3Schema | undefined { diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index c5e057e2b26..5e134e7a0a9 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,8 +1,8 @@ import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { OpenAPISpecHelpers, supportedVersions, worksFor } from "./works-for.js"; -worksFor(supportedVersions, ({ openApiFor }) => { +worksFor(supportedVersions, ({ openApiFor, openapisFor }) => { const testCases: [string, string, string, any][] = [ [ "set tag metadata", @@ -101,6 +101,54 @@ worksFor(supportedVersions, ({ openApiFor }) => { deepStrictEqual(res.tags, expected); }); + + it("tagMetadata do not gets applied to another service without tags", async () => { + const res = await openapisFor(` + @service + @tagMetadata( + "CatTag", #{ description: "Cat operations" } + ) + namespace CatStore {} + + @service + namespace DogStore {} + `); + expect(res["openapi.CatStore.json"].tags).toEqual([ + { + description: "Cat operations", + name: "CatTag", + }, + ]); + expect(res["openapi.DogStore.json"].tags).toEqual([]); + }); + + it("tagMetadata only affect the service they are defined on", async () => { + const res = await openapisFor(` + @service + @tagMetadata( + "CatTag", #{ description: "Cat operations" } + ) + namespace CatStore {} + + @service + @tagMetadata( + "DogTag", #{ description: "Dog operations" } + ) + namespace DogStore {} + `); + expect(res["openapi.CatStore.json"].tags).toEqual([ + { + description: "Cat operations", + name: "CatTag", + }, + ]); + expect(res["openapi.DogStore.json"].tags).toEqual([ + { + description: "Dog operations", + name: "DogTag", + }, + ]); + }); }); // Test for parent field - version specific behavior diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 5b5a6c306e3..2a3dde4136c 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -78,6 +78,37 @@ export async function openApiFor(code: string, options: OpenAPI3EmitterOptions = return JSON.parse(outputs["openapi.json"]); } +/** + * Emit multiple openapi documents from the given tsp code. + * @example + * + * ```ts + * const result = await openapisFor(` + * @service namespace MyServiceV1 {} + * @service namespace MyServiceV2 {} + * `) + * + * results => { + * "openapi.MyServiceV1.json": { ... }, + * "openapi.MyServiceV2.json": { ... }, + * } + * ``` + */ +export async function openapisFor( + code: string, + options: OpenAPI3EmitterOptions = {}, +): Promise> { + const { outputs } = await SimpleTester.compile(code, { + compilerOptions: { + options: { "@typespec/openapi3": { ...options, "file-type": "json" } }, + }, + }); + + return Object.fromEntries( + Object.entries(outputs).map(([k, v]) => [k, JSON.parse(v) as OpenAPI3Document]), + ); +} + export async function openApiForVersions( code: string, versions: T[], diff --git a/packages/openapi3/test/works-for.ts b/packages/openapi3/test/works-for.ts index b344e22d626..ad626511e35 100644 --- a/packages/openapi3/test/works-for.ts +++ b/packages/openapi3/test/works-for.ts @@ -5,6 +5,7 @@ import { emitOpenApiWithDiagnostics, oapiForModel, openApiFor, + openapisFor, openapiWithOptions, } from "./test-host.js"; @@ -20,6 +21,7 @@ export type SpecHelper = { version: OpenAPIVersion; oapiForModel: typeof oapiForModel; openApiFor: typeof openApiFor; + openapisFor: typeof openapisFor; openapiWithOptions: typeof openapiWithOptions; checkFor: typeof diagnoseOpenApiFor; diagnoseOpenApiFor: typeof diagnoseOpenApiFor; @@ -36,6 +38,8 @@ function createSpecHelpers(version: OpenAPIVersion): SpecHelper { oapiForModel(name, modelDef, { ...options, "openapi-versions": [version] }), openApiFor: (...[code, options]: Parameters) => openApiFor(code, { ...options, "openapi-versions": [version] }), + openapisFor: (...[code, options]: Parameters) => + openapisFor(code, { ...options, "openapi-versions": [version] }), openapiWithOptions: (...[code, options]: Parameters) => openapiWithOptions(code, { ...options, "openapi-versions": [version] }), checkFor: (...[code, options]: Parameters) => From da7c83acb62aa358472aa495dd6cb00d19b7ce44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:09:11 +0000 Subject: [PATCH 7/9] Drop parent field for OpenAPI 3.0/3.1 instead of converting to x-parent - Changed implementation to drop parent field entirely for 3.0/3.1 - Updated tests to reflect parent field is dropped (not converted) - Only OpenAPI 3.2 emits the parent field Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/openapi3/src/openapi.ts | 3 +-- packages/openapi3/test/tagmetadata.test.ts | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 75edd64f4f7..a3d27481723 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1783,9 +1783,8 @@ function createOAPIEmitter( for (const [name, tag] of Object.entries(metadata || {})) { const tagData: OpenAPI3Tag = { name: name, ...tag }; - // For OpenAPI 3.0 and 3.1, convert 'parent' to 'x-parent' extension + // For OpenAPI 3.0 and 3.1, drop the 'parent' field (only supported in 3.2) if (specVersion !== "3.2.0" && tag.parent) { - tagData["x-parent"] = tag.parent; delete (tagData as { parent?: string }).parent; } tags.push(tagData); diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 1128da146fb..d05727b4f74 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -180,7 +180,7 @@ describe("tag metadata with parent field", () => { ]); }); - it("OpenAPI 3.1 should convert parent to x-parent", async () => { + it("OpenAPI 3.1 should drop parent field", async () => { const res = await OpenAPISpecHelpers["3.1.0"].openApiFor( ` @service @@ -196,7 +196,6 @@ describe("tag metadata with parent field", () => { { name: "ChildTag", description: "Child tag", - "x-parent": "ParentTag", }, { name: "ParentTag", @@ -205,7 +204,7 @@ describe("tag metadata with parent field", () => { ]); }); - it("OpenAPI 3.0 should convert parent to x-parent", async () => { + it("OpenAPI 3.0 should drop parent field", async () => { const res = await OpenAPISpecHelpers["3.0.0"].openApiFor( ` @service @@ -221,7 +220,6 @@ describe("tag metadata with parent field", () => { { name: "ChildTag", description: "Child tag", - "x-parent": "ParentTag", }, { name: "ParentTag", From d9d851c25745ef4820fee945bbd452e3783df357 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 5 Feb 2026 08:10:34 -0800 Subject: [PATCH 8/9] Update tagmetadata.test.ts --- packages/openapi3/test/tagmetadata.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index d05727b4f74..e2ab9869983 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,6 +1,5 @@ import { deepStrictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { supportedVersions, worksFor } from "./works-for.js"; import { OpenAPISpecHelpers, supportedVersions, worksFor } from "./works-for.js"; From 6facdb996bc14368872320d57d90b558b9489c75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:56:47 +0000 Subject: [PATCH 9/9] Run pnpm format to fix formatting issues - Removed extra blank line in tagmetadata.test.ts Co-authored-by: timotheeguerin <1031227+timotheeguerin@users.noreply.github.com> --- packages/openapi3/test/tagmetadata.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index e2ab9869983..946a0d5a744 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -2,7 +2,6 @@ import { deepStrictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { OpenAPISpecHelpers, supportedVersions, worksFor } from "./works-for.js"; - worksFor(supportedVersions, ({ openApiFor, openapisFor }) => { const testCases: [string, string, string, any][] = [ [