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
39 changes: 23 additions & 16 deletions packages/http-client-csharp/emitter/src/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/http-client-csharp/emitter/src/lib/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ const diags: { [code: string]: DiagnosticDefinition<DiagnosticMessages> } = {
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.`,
},
},
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Microsoft.TypeSpec.Generator.Tests.Common;
using NUnit.Framework;
using System.Text.Json;

namespace Microsoft.TypeSpec.Generator.Input.Tests
{
Expand All @@ -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<JsonException>(() => TypeSpecSerialization.Deserialize(json));

Assert.That(ex!.Message, Does.Contain("name"));
}
}
}
8 changes: 7 additions & 1 deletion packages/http-client-csharp/playground-server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Loading