Skip to content
Merged
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
3 changes: 2 additions & 1 deletion generators/csharp/base/src/context/CsharpTypeMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export class CsharpTypeMapper extends WithGeneration {
return property.isOptional ? this.Types.FileParameter.asOptional() : this.Types.FileParameter;
}
case "fileArray": {
return property.isOptional ? this.Types.FileParameter.asOptional() : this.Types.FileParameter;
const listType = this.Collection.list(this.Types.FileParameter);
return property.isOptional ? listType.asOptional() : listType;
}
default:
assertNever(property);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export class DynamicLiteralMapper extends WithGeneration {
value: unknown;
fallbackToDefault?: string;
}): ast.Literal {
// When generateLiterals is enabled, inline literal properties use `= new()`
// default initializers in the C# model and must not be set in the snippet.
if (this.settings.generateLiterals) {
return this.csharp.Literal.nop();
}
Comment on lines +145 to +147
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 convertLiteral blanket nop() return drops literal path parameter arguments, causing compilation errors

When generateLiterals is enabled, convertLiteral returns nop() unconditionally (line 145-146). This is correct for inline object properties (where Class_ literal filters out nop fields at Literal.ts:115), but breaks path parameters that are passed as standalone method arguments. The MethodInvocation.write() method (MethodInvocation.ts:95-106) does NOT filter out nop arguments — it just calls arg.write(writer) which writes nothing. This causes the literal path parameter value to be silently dropped from the method call.

The generated snippet for the literal/readonly-constants fixture confirms this: await client.Path.SendAsync() is missing the required string id argument (see PathClient.cs:83-86 which requires string id). The fallbackToDefault parameter set at EndpointSnippetGenerator.ts:860 is never reached because the early return on line 146 fires first.

Affected code flow
  1. getPathParameters (EndpointSnippetGenerator.ts:855-862) calls convert() on each path param
  2. For literal<"123">, convert() dispatches to convertLiteral()
  3. convertLiteral() returns nop() at line 146 before any value logic
  4. The nop is used as a method argument at EndpointSnippetGenerator.ts:779 or :584
  5. MethodInvocation renders the nop as empty output
  6. Result: SendAsync() instead of SendAsync("123") — CS7036 compilation error
Prompt for agents
The convertLiteral method in DynamicLiteralMapper.ts has a blanket early return that returns nop() when generateLiterals is true. This is too aggressive — it works for inline object properties (which are filtered by Class_ literal's nop filtering in Literal.ts:115), but breaks literal values used as standalone method arguments (path parameters, body request arguments), because MethodInvocation does not filter out nop arguments.

The fix needs to preserve the nop behavior for inline properties while still emitting literal values when they're needed as method arguments. Possible approaches:

1. Remove the early return from convertLiteral and instead handle literal suppression at the object-property level. The Class_ literal already filters nop fields, so you could keep returning the actual literal value and add a separate check in convertObject or at the ConstructorField level to skip literal-typed properties when generateLiterals is on.

2. Add a context flag (e.g., suppressLiterals: boolean) to the DynamicLiteralMapper.Args interface, defaulting to false. Only callers that generate inline properties (convertObject, getInlinedRequestBodyPropertyConstructorFields, header/query fields) would pass suppressLiterals: true. Path parameter callers in EndpointSnippetGenerator.getPathParameters would not set this flag.

3. In EndpointSnippetGenerator.getPathParameters, special-case literal type references: when generateLiterals is true and the parameter typeReference.type is 'literal', emit the literal value directly instead of going through convert().

The affected callers are in EndpointSnippetGenerator.ts: getPathParameters (line 843), getMethodArgsForBodyRequest (line 767), and getMethodArgsForInlinedRequest (line 556). Files: generators/csharp/dynamic-snippets/src/context/DynamicLiteralMapper.ts and generators/csharp/dynamic-snippets/src/EndpointSnippetGenerator.ts.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

switch (literal.type) {
case "boolean": {
const bool = this.context.getValueAsBoolean({ value });
Expand Down Expand Up @@ -240,6 +245,21 @@ export class DynamicLiteralMapper extends WithGeneration {
}): ast.Literal {
switch (named.type) {
case "alias":
// When generateLiterals is enabled and the alias resolves to a literal,
// the C# model emits a readonly struct (e.g. `FormatMp3`) instead of a
// raw string/bool. Instantiate it with `new TypeName()` rather than
// emitting a plain literal value.
if (this.settings.generateLiterals && named.typeReference.type === "literal") {
return this.csharp.Literal.reference(
this.csharp.instantiateClass({
classReference: this.csharp.classReference({
origin: named.declaration,
namespace: this.context.getNamespace(named.declaration.fernFilepath)
}),
arguments_: []
})
);
}
return this.convert({ typeReference: named.typeReference, value, as, fallbackToDefault });
case "discriminatedUnion":
if (this.settings.shouldGeneratedDiscriminatedUnions) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export class DynamicTypeMapper extends WithGeneration {
private convertNamed({ named }: { named: FernIr.dynamic.NamedType }): ast.Type {
switch (named.type) {
case "alias":
if (this.settings.generateLiterals && named.typeReference.type === "literal") {
return this.csharp.classReference({
origin: named.declaration,
namespace: this.context.getNamespace(named.declaration.fernFilepath)
});
}
return this.convert({ typeReference: named.typeReference });
case "enum":
case "object":
Expand Down
21 changes: 20 additions & 1 deletion generators/csharp/sdk/src/generateSdkTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ function generateMockServerTests({ context }: { context: SdkGeneratorContext }):
// TODO: support other response body types
const useableExamples = allExamples.filter((example): example is FernIr.ExampleEndpointCall => {
const response = example?.response;
return response?.type === "ok" && response.value.type === "body";
if (response?.type !== "ok" || response.value.type !== "body") {
return false;
}
// Skip examples with empty string path parameters. An empty path parameter
// causes a URL mismatch: the mock server registers a collapsed path (e.g.
// /v0/tools/version/1) while the SDK client sends a double-slash path (e.g.
// /v0/tools//version/1), resulting in a 404 from WireMock.
if (example != null && hasEmptyPathParameter(example)) {
return false;
}
return true;
});
if (useableExamples.length === 0) {
continue;
Expand All @@ -49,6 +59,15 @@ function generateMockServerTests({ context }: { context: SdkGeneratorContext }):
return files;
}

function hasEmptyPathParameter(example: FernIr.ExampleEndpointCall): boolean {
const allPathParams = [
...example.rootPathParameters,
...example.servicePathParameters,
...example.endpointPathParameters
];
return allPathParams.some((param) => param.value.jsonExample === "");
}

function shouldSkipMockServerTestForEndpoint({ endpoint }: { endpoint: FernIr.HttpEndpoint }): boolean {
const responseBodyType = endpoint.response?.body?.type;
if (responseBodyType === "fileDownload" || responseBodyType === "streamParameter") {
Expand Down
22 changes: 22 additions & 0 deletions generators/csharp/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 2.59.5
changelogEntry:
- summary: |
Skip mock server test examples with empty string path parameters. An
empty path parameter causes the mock server to register a collapsed URL
(e.g. `/v0/tools/version/1`) while the SDK client sends a double-slash
URL (e.g. `/v0/tools//version/1`), resulting in a 404 from WireMock.
type: fix
- summary: |
Fix dynamic snippet generation for literal types when `generate-literals`
(or `experimental-readonly-constants`) is enabled. Inline literal
properties now correctly omit the value (relying on the `= new()`
default initializer), and named literal alias types emit `new TypeName()`
instead of a raw string, preventing CS0029 compilation errors.
type: fix
- summary: |
Fix file upload request properties declared as `list<file>` generating
a single `FileParameter` field instead of `List<FileParameter>`.
type: fix
createdAt: "2026-04-10"
irVersion: 66

- version: 2.59.4
changelogEntry:
- summary: |
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 1 addition & 5 deletions seed/csharp-sdk/seed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,6 @@ fixtures:
redact-response-body-on-error: true
outputFolder: redact-response-body-on-error
allowedFailures:
- schemaless-request-body-examples
- bytes-upload
- enum:forward-compatible-enums
- imdb:exported-client-class-name
Expand All @@ -412,10 +411,7 @@ allowedFailures:
- package-yml
- pagination-uri-path
- property-access
- trace
- unions:no-custom-config
- unions-with-local-date
- server-sent-event-examples
- oauth-client-credentials-openapi
- server-sent-events-openapi
- streaming-parameter
- server-sent-events-openapi
Loading