diff --git a/packages/http-client-csharp/emitter/src/emitter.ts b/packages/http-client-csharp/emitter/src/emitter.ts index c49591c53d2..1aaa9c698f2 100644 --- a/packages/http-client-csharp/emitter/src/emitter.ts +++ b/packages/http-client-csharp/emitter/src/emitter.ts @@ -81,24 +81,31 @@ export async function emitCodeModel( const updatedRoot = updateCodeModel ? updateCodeModel(root, sdkContext) : root; const namespace = updatedRoot.name; - const configurations: Configuration = createConfiguration(options, namespace, sdkContext); - // Serialize code model and configuration - const codeModelJson = serializeCodeModel(sdkContext, updatedRoot); - const configJson = JSON.stringify(configurations, null, 2) + "\n"; + // If the namespace could not be resolved, createModel has already reported an + // actionable diagnostic. Skip generation so we don't send an unnamed code model that + // fails to deserialize in the .NET generator with an opaque root-level error. + // See https://github.com/microsoft/typespec/issues/10914. + if (namespace) { + const configurations: Configuration = createConfiguration(options, namespace, sdkContext); - // Generate C# code via platform-specific implementation. - // In Node.js this runs the .NET generator locally. - // In the browser this sends the code model to a playground server. - await generate(sdkContext, codeModelJson, configJson, { - outputFolder, - packageName: configurations["package-name"] ?? "", - generatorName: options["generator-name"], - newProject: options["new-project"], - debug: options.debug ?? false, - saveInputs: options["save-inputs"] ?? false, - emitterExtensionPath: options["emitter-extension-path"], - }); + // Serialize code model and configuration + const codeModelJson = serializeCodeModel(sdkContext, updatedRoot); + const configJson = JSON.stringify(configurations, null, 2) + "\n"; + + // Generate C# code via platform-specific implementation. + // In Node.js this runs the .NET generator locally. + // In the browser this sends the code model to a playground server. + await generate(sdkContext, codeModelJson, configJson, { + outputFolder, + packageName: configurations["package-name"] ?? "", + generatorName: options["generator-name"], + newProject: options["new-project"], + debug: options.debug ?? false, + saveInputs: options["save-inputs"] ?? false, + emitterExtensionPath: options["emitter-extension-path"], + }); + } } } diff --git a/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts b/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts index c0d45fc6e67..f1165eff1a6 100644 --- a/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts +++ b/packages/http-client-csharp/emitter/src/lib/client-model-builder.ts @@ -6,11 +6,12 @@ import { SdkEnumType, SdkHttpOperation, } from "@azure-tools/typespec-client-generator-core"; -import { createDiagnosticCollector, Diagnostic } from "@typespec/compiler"; +import { createDiagnosticCollector, Diagnostic, NoTarget } from "@typespec/compiler"; import { CSharpEmitterContext } from "../sdk-context.js"; import { CodeModel } from "../type/code-model.js"; import { InputEnumType, InputLiteralType, InputModelType } from "../type/input-type.js"; import { fromSdkClients } from "./client-converter.js"; +import { createDiagnostic } from "./lib.js"; import { fromSdkNamespaces } from "./namespace-converter.js"; import { processServiceAuthentication } from "./service-authentication.js"; import { fromSdkType } from "./type-converter.js"; @@ -97,8 +98,22 @@ export function createModel(sdkContext: CSharpEmitterContext): [CodeModel, reado // Fix naming conflicts for constants, enums, and models fixNamingConflicts(models, constants); + // Resolve the root namespace. When this cannot be determined the generated code model + // has no name, which the .NET generator rejects with an opaque root-level deserialization + // failure. Surface a clear, actionable diagnostic here instead and let the caller skip + // generation. See https://github.com/microsoft/typespec/issues/10914. + const clientNamespace = getClientNamespaceString(sdkContext); + if (clientNamespace === undefined) { + diagnostics.add( + createDiagnostic({ + code: "unresolved-client-namespace", + target: NoTarget, + }), + ); + } + const clientModel: CodeModel = { - name: getClientNamespaceString(sdkContext)!, + name: clientNamespace ?? "", apiVersions: rootApiVersions, enums: enums, constants: constants, diff --git a/packages/http-client-csharp/emitter/src/lib/lib.ts b/packages/http-client-csharp/emitter/src/lib/lib.ts index 1d0fd759545..c93ac07963d 100644 --- a/packages/http-client-csharp/emitter/src/lib/lib.ts +++ b/packages/http-client-csharp/emitter/src/lib/lib.ts @@ -107,6 +107,12 @@ const diags: { [code: string]: DiagnosticDefinition } = { default: paramMessage`Unsupported continuation location for operation ${"crossLanguageDefinitionId"}.`, }, }, + "unresolved-client-namespace": { + severity: "error", + messages: { + default: `Unable to determine a namespace for the generated client. Ensure the spec defines a service namespace (using the \`@service\` decorator) or set the \`package-name\` emitter option.`, + }, + }, }; /** diff --git a/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts b/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts index 5ce773fe969..954a7296b4b 100644 --- a/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts +++ b/packages/http-client-csharp/emitter/test/Unit/client-model-builder.test.ts @@ -568,4 +568,28 @@ describe("createModel diagnostic collection", () => { ok(diagnostics !== undefined, "Diagnostics should not be undefined"); ok(Array.isArray(diagnostics), "Diagnostics should be an array"); }); + + it("reports an unresolved-client-namespace diagnostic when no namespace can be resolved", async () => { + const program = await typeSpecCompile( + ` + @route("/test") + op test(): void; + `, + runner, + { IsNamespaceNeeded: false, IsVersionNeeded: false }, + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [codeModel, diagnostics] = createModel(sdkContext); + + ok( + diagnostics.some( + (d) => d.code === "@typespec/http-client-csharp/unresolved-client-namespace", + ), + "Expected an unresolved-client-namespace diagnostic", + ); + // The code model name is empty so callers can skip generation instead of + // sending an unnamed model that fails to deserialize in the generator. + strictEqual(codeModel.name, "", "CodeModel name should be empty when unresolved"); + }); }); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs index 028e969e489..ab1d30f3235 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/src/InputTypes/Serialization/InputNamespaceConverter.cs @@ -61,7 +61,9 @@ public override void Write(Utf8JsonWriter writer, InputNamespace value, JsonSeri clients ??= []; return new InputNamespace( - name ?? throw new JsonException(), + name ?? throw new JsonException( + "Required property 'name' was missing or null on the root InputNamespace. " + + "The input code model is incomplete or was corrupted in transit."), apiVersions, constants, enums, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs index de162c40d12..e52dc4d551b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.Input/test/InputNamespaceTests.cs @@ -3,6 +3,7 @@ using Microsoft.TypeSpec.Generator.Tests.Common; using NUnit.Framework; +using System.Text.Json; namespace Microsoft.TypeSpec.Generator.Input.Tests { @@ -22,5 +23,18 @@ public void CalculatesAllClients() Assert.AreEqual(1, inputNamespace.RootClients.Count); Assert.AreEqual("Client1", inputNamespace.RootClients[0].Name); } + + [Test] + public void DeserializeThrowsDescriptiveErrorWhenNameMissing() + { + // A code model whose root object has no "name" property (e.g. an incomplete or + // truncated payload) must fail with an actionable message naming the missing field, + // not a bare "could not be converted to InputNamespace". + var json = "{\n \"$id\": \"1\",\n \"apiVersions\": []\n}"; + + var ex = Assert.Throws(() => TypeSpecSerialization.Deserialize(json)); + + Assert.That(ex!.Message, Does.Contain("name")); + } } } diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 684d95f4967..1eac52a2845 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -123,6 +123,10 @@ } Console.WriteLine($"Generator version: {generatorVersion}"); +// Root route exists so Azure App Service platform probes (Always On warm-up +// and availability pings hit "/") get a 200 instead of logging noisy 404s. +app.MapGet("/", () => Results.Ok(new { status = "ok", service = "csharp-playground-server" })); + app.MapGet("/health", () => { string dotnetVersion; @@ -220,9 +224,11 @@ void TrackGenerateEvent(GenerateOutcome outcome) try { // Write the input files the generator expects - await File.WriteAllTextAsync(Path.Combine(tempDir, "tspCodeModel.json"), body.CodeModel); + var codeModelPath = Path.Combine(tempDir, "tspCodeModel.json"); + await File.WriteAllTextAsync(codeModelPath, body.CodeModel); await File.WriteAllTextAsync(Path.Combine(tempDir, "Configuration.json"), body.Configuration); + // Run the .NET generator as a subprocess Console.WriteLine($"Starting generator: dotnet --roll-forward Major {generatorPath} {tempDir} -g {generatorName} --new-project"); Console.WriteLine($"Code model size: {body.CodeModel!.Length} chars");