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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,14 @@ private bool CompareJsonElements(JsonElement x, JsonElement y, string path)
case JsonValueKind.Number:
if (x.GetDecimal() != y.GetDecimal())
{
_failurePath = $"{path}: Expected {x.GetDecimal()} but got {y.GetDecimal()}";
return false;
if (x.GetDouble() != y.GetDouble())
{
if (x.GetSingle() != y.GetSingle())
{
_failurePath = $"{path}: Expected {x.GetDecimal()} but got {y.GetDecimal()}";
return false;
}
}
}

return true;
Expand Down
4 changes: 4 additions & 0 deletions generators/csharp/base/src/context/GeneratorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,10 @@ export abstract class GeneratorContext extends AbstractGeneratorContext {
return undefined;
}

public isLiteralValue(typeReference: TypeReference): boolean {
return this.getLiteralValue(typeReference) != null;
}

public getLiteralValue(typeReference: TypeReference): string | boolean | undefined {
if (typeReference.type === "container" && typeReference.container.type === "literal") {
const literal = typeReference.container.literal;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ export class EndpointSnippetGenerator extends WithGeneration {
snippet: FernIr.dynamic.EndpointSnippetRequest;
options: Options;
}): ast.AstNode {
// if we're actually passed the examples, we need to
// check that the endpoint that we're generating has an example that matches the snippet
// If we're passed endpoint examples and the snippet includes an id (i.e. it's an
// EndpointExample, not just an EndpointSnippetRequest), verify the id matches one of
// the examples. Snippets without an id are user-provided requests and skip this check.
if (
endpoint.examples &&
!endpoint.examples?.find((each) => is.DynamicIR.EndpointExample(snippet) && each.id === snippet.id)
is.DynamicIR.EndpointExample(snippet) &&
!endpoint.examples.find((each) => each.id === snippet.id)
) {
// the dsg expects us to just throw when there is nothing to generate.
throw new Error("Endpoint does not have an example that matches the snippet");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { DynamicSnippetsTestRunner } from "@fern-api/browser-compatible-base-generator";
import { DynamicSnippetsTestRunner, Style } from "@fern-api/browser-compatible-base-generator";
import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { AbsoluteFilePath, join } from "@fern-api/path-utils";
import { readFileSync } from "fs";

import { DynamicSnippetsGenerator } from "../DynamicSnippetsGenerator.js";
import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator.js";
import { buildGeneratorConfig } from "./utils/buildGeneratorConfig.js";

Expand All @@ -16,6 +19,71 @@ const DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY = AbsoluteFilePath.of(
`${__dirname}/../../../../../packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions`
);

describe("snippets (endpoint with examples)", () => {
test("generates snippet for EndpointSnippetRequest when endpoint has examples", async () => {
const irFilepath = AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "imdb.json"));
const ir: FernIr.dynamic.DynamicIntermediateRepresentation = JSON.parse(readFileSync(irFilepath, "utf-8"));

// Inject examples onto the POST /movies/create-movie endpoint so the guard is exercised.
for (const endpoint of Object.values(ir.endpoints)) {
if (endpoint.location.path === "/movies/create-movie" && endpoint.location.method === "POST") {
endpoint.examples = [
{
id: "example-1",
name: undefined,
endpoint: endpoint.location,
baseURL: undefined,
environment: undefined,
auth: {
type: "bearer",
token: "<token>"
},
pathParameters: undefined,
queryParameters: undefined,
headers: undefined,
requestBody: {
title: "Example Movie",
rating: 7.5
}
}
];
}
}

const generator = new DynamicSnippetsGenerator({
ir,
config: buildGeneratorConfig(),
options: { style: Style.Concise }
});

// Call generate with a plain EndpointSnippetRequest (no id).
// Before the fix, this threw "Endpoint does not have an example that matches the snippet".
const response = await generator.generate({
endpoint: {
method: "POST",
path: "/movies/create-movie"
},
baseURL: undefined,
environment: undefined,
auth: {
type: "bearer",
token: "<YOUR_API_KEY>"
},
pathParameters: undefined,
queryParameters: undefined,
headers: undefined,
requestBody: {
title: "The Matrix",
rating: 8.2
}
});

expect(response.errors).toBeUndefined();
expect(response.snippet).toBeTruthy();
expect(response.snippet).toMatchSnapshot();
});
});

describe("snippets (use-undiscriminated-unions)", () => {
const generator = buildDynamicSnippetsGenerator({
irFilepath: AbsoluteFilePath.of(join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "exhaustive.json")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,32 +276,29 @@ await client.Endpoints.Container.GetAndReturnListOfPrimitivesAsync(
`;

exports[`snippets (default) > exhaustive > 'POST /container/map-prim-to-union (mi…' 1`] = `
"[
{
"severity": "CRITICAL",
"path": [
"requestBody",
"test2"
],
"message": "Expected number, got boolean"
},
{
"severity": "CRITICAL",
"path": [
"requestBody",
"test3"
],
"message": "Expected number, got string"
},
{
"severity": "CRITICAL",
"path": [
"requestBody",
"test4"
],
"message": "Expected number, got object"
}
]"
"using Acme;
using System.Collections.Generic;
using OneOf;

var client = new AcmeClient(
token: "<YOUR_API_KEY>"
);

await client.Endpoints.Container.GetAndReturnMapOfPrimToUndiscriminatedUnionAsync(
new Dictionary<string, OneOf<double, bool, string, IEnumerable<string>>>(){
["test"] = 0,
["test2"] = true,
["test3"] = "Test",
["test4"] = new List<string>(){
"1",
"1",
"1",
"1",
}
,
}
);
"
`;

exports[`snippets (default) > file-upload > 'POST /' 1`] = `
Expand Down Expand Up @@ -706,6 +703,23 @@ exports[`snippets (default) > single-url-environment-default > 'invalid environm
]"
`;

exports[`snippets (endpoint with examples) > generates snippet for EndpointSnippetRequest when endpoint has examples 1`] = `
"using Acme;
using Acme.Imdb;

var client = new AcmeClient(
token: "<YOUR_API_KEY>"
);

await client.Imdb.CreateMovieAsync(
new CreateMovieRequest {
Title = "The Matrix",
Rating = 8.2
}
);
"
`;

exports[`snippets (use-undiscriminated-unions) > POST /container/map-prim-to-union (mixed values) 1`] = `
"using Acme;
using System.Collections.Generic;
Expand All @@ -718,6 +732,15 @@ var client = new AcmeClient(
await client.Endpoints.Container.GetAndReturnMapOfPrimToUndiscriminatedUnionAsync(
new Dictionary<string, MixedType>(){
["test"] = 0,
["test2"] = true,
["test3"] = "Test",
["test4"] = new List<string>(){
"1",
"1",
"1",
"1",
}
,
}
);
"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -657,10 +657,16 @@ export class DynamicLiteralMapper extends WithGeneration {
value: unknown;
}): { valueTypeReference: FernIr.dynamic.TypeReference; typeLiteral: ast.Literal } | undefined {
for (const typeReference of undiscriminatedUnion.types) {
const errorsBefore = this.context.errors.size();
try {
const typeLiteral = this.convert({ typeReference, value });
if (is.Literal.nop(typeLiteral) || this.context.errors.size() > errorsBefore) {
this.context.errors.truncate(errorsBefore);
continue;
}
return { valueTypeReference: typeReference, typeLiteral };
} catch (e) {
this.context.errors.truncate(errorsBefore);
continue;
}
}
Expand Down
46 changes: 34 additions & 12 deletions generators/csharp/model/src/object/ObjectGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,19 +173,37 @@ export class ObjectGenerator extends FileGenerator<CSharpFile, ModelGeneratorCon
exampleObject: ExampleObjectType;
parseDatetimes: boolean;
}): ast.CodeBlock {
const args = exampleObject.properties.map((exampleProperty) => {
const propertyName = this.getPropertyName({
className: this.classReference.name,
objectProperty: exampleProperty.name
});
const assignment = this.exampleGenerator.getSnippetForTypeReference({
exampleTypeReference: exampleProperty.value,
parseDatetimes
// When generateLiterals is enabled, collect wire values of literal properties so we
// can skip them in the object initializer. Their default `= new()` already sets the
// correct value and assigning a plain string would cause a CS0029 compilation error.
const literalPropertyWireValues = new Set<string>();
if (this.generation.settings.generateLiterals) {
const allProps = [
...this.objectDeclaration.properties,
...(this.objectDeclaration.extendedProperties ?? [])
];
for (const prop of allProps) {
if (this.context.isLiteralValue(prop.valueType)) {
literalPropertyWireValues.add(getWireValue(prop.name));
}
}
}

const args = exampleObject.properties
.filter((exampleProperty) => !literalPropertyWireValues.has(getWireValue(exampleProperty.name)))
.map((exampleProperty) => {
const propertyName = this.getPropertyName({
className: this.classReference.name,
objectProperty: exampleProperty.name
});
const assignment = this.exampleGenerator.getSnippetForTypeReference({
exampleTypeReference: exampleProperty.value,
parseDatetimes
});
// todo: considering filtering out "assignments" are are actually just null so that null properties
// are completely excluded from object initializers
return { name: propertyName, assignment };
});
// todo: considering filtering out "assignments" are are actually just null so that null properties
// are completely excluded from object initializers
return { name: propertyName, assignment };
});

// Include default values for required properties missing from the example
// so the generated object initializer compiles without CS9035 errors.
Expand All @@ -198,6 +216,10 @@ export class ObjectGenerator extends FileGenerator<CSharpFile, ModelGeneratorCon
if (providedWireValues.has(getWireValue(property.name))) {
continue;
}
// Skip literal properties when generateLiterals is enabled; they have correct defaults.
if (literalPropertyWireValues.has(getWireValue(property.name))) {
continue;
}
if (this.isRequiredProperty(property.valueType)) {
const propertyName = this.getPropertyName({
className: this.classReference.name,
Expand Down
12 changes: 12 additions & 0 deletions generators/csharp/model/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 0.9.1-rc.0
changelogEntry:
- summary: |
Fix snippet generation to skip literal properties in object initializers
when `generate-literals` is enabled. Previously, the generator emitted
plain string/boolean assignments for `TypeLiteral` properties, causing
CS0029 compilation errors. Literal properties now rely on their `= new()`
default initializer.
type: fix
createdAt: "2026-04-09"
irVersion: 66

- version: 0.9.0-rc.0
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,22 @@ export class WrappedRequestGenerator extends FileGenerator<CSharpFile, SdkGenera
});
}

// When generateLiterals is enabled, collect wire values of literal properties from
// the endpoint's inlined request body so we can skip them in the object initializer.
// Their default `= new()` already sets the correct value and assigning a plain string
// would cause a CS0029 compilation error.
const literalBodyPropertyWireValues = new Set<string>();
if (this.generation.settings.generateLiterals && this.endpoint.requestBody?.type === "inlinedRequestBody") {
for (const prop of [
...this.endpoint.requestBody.properties,
...(this.endpoint.requestBody.extendedProperties ?? [])
]) {
if (this.context.isLiteralValue(prop.valueType)) {
literalBodyPropertyWireValues.add(getWireValue(prop.name));
}
}
}

example.request?._visit({
reference: (reference) => {
orderedFields.push({
Expand All @@ -357,6 +373,9 @@ export class WrappedRequestGenerator extends FileGenerator<CSharpFile, SdkGenera
},
inlinedRequestBody: (inlinedRequestBody) => {
for (const property of inlinedRequestBody.properties) {
if (literalBodyPropertyWireValues.has(getWireValue(property.name))) {
continue;
}
orderedFields.push({
name: property.name,
value: this.exampleGenerator.getSnippetForTypeReference({
Expand Down
Loading
Loading