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
75 changes: 73 additions & 2 deletions generators/go/internal/generator/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,11 +756,16 @@ func (f *fileWriter) WriteRequestOptionsDefinition(
continue
}
value := valueTypeFormat.Prefix + "r." + header.Name.Name.PascalCase.UnsafeName + valueTypeFormat.Suffix
if valueTypeFormat.IsOptional {
switch {
case valueTypeFormat.IsOptional:
f.P("if r.", header.Name.Name.PascalCase.UnsafeName, " != nil {")
f.P(`header.Set("`, header.Name.WireValue, `", fmt.Sprintf("%v", `, value, "))")
f.P("}")
} else {
case isComparableHeaderValueType(header.ValueType, valueTypeFormat, f.types):
f.P("if ", headerIsSetCondition("r."+header.Name.Name.PascalCase.UnsafeName, header.ValueType, valueTypeFormat), " {")
f.P(`header.Set("`, header.Name.WireValue, `", fmt.Sprintf("%v", `, value, "))")
f.P("}")
default:
f.P(`header.Set("`, header.Name.WireValue, `", fmt.Sprintf("%v", `, value, "))")
}
}
Expand Down Expand Up @@ -4978,6 +4983,72 @@ func isClientDefaultResolvedAtConstruction(valueType *ir.TypeReference, valueTyp
return valueType.Primitive.V1 == common.PrimitiveTypeV1String || valueType.Primitive.V1 == common.PrimitiveTypeV1Boolean
}

// isComparableHeaderValueType returns true if a header's field can be checked
// against its zero value in the generated code, so that a header left unset is
// omitted rather than sent with an empty value (matching how the auth scheme
// header is generated). Only types whose zero value is never a meaningful wire
// value are supported: booleans and numbers are excluded because false and 0
// are legitimate values that cannot be distinguished from an unset field, and
// so are iterables and composite types (objects, lists, maps, unions). Named
// types are only supported when they resolve to an enum; an alias of a
// primitive stays unguarded because the alias' zero value is not necessarily
// the underlying primitive's.
func isComparableHeaderValueType(valueType *ir.TypeReference, valueTypeFormat *valueTypeFormat, types map[common.TypeId]*ir.TypeDeclaration) bool {
if valueTypeFormat.IsIterable || valueTypeFormat.IsOptional {
return false
}
if primitive := maybePrimitive(valueType); primitive != nil {
switch primitive.V1 {
case common.PrimitiveTypeV1String,
common.PrimitiveTypeV1BigInteger,
common.PrimitiveTypeV1Uuid,
common.PrimitiveTypeV1Base64,
common.PrimitiveTypeV1Date,
common.PrimitiveTypeV1DateTime:
return true
}
return false
}
return isEnumType(valueType, types)
}

// headerIsSetCondition returns the condition used to determine whether a header
// field holds a value, e.g. `r.Version != ""`. The caller must first verify the
// type is supported with isComparableHeaderValueType.
func headerIsSetCondition(field string, valueType *ir.TypeReference, valueTypeFormat *valueTypeFormat) string {
if primitive := maybePrimitive(valueType); primitive != nil {
switch primitive.V1 {
case common.PrimitiveTypeV1DateTime, common.PrimitiveTypeV1Date:
// time.Time is not comparable with the != operator against a composite literal.
return "!" + field + ".IsZero()"
}
}
return field + " != " + valueTypeFormat.ZeroValue
}

// isEnumType returns true if the given type reference resolves to an enum,
// following alias chains.
func isEnumType(valueType *ir.TypeReference, types map[common.TypeId]*ir.TypeDeclaration) bool {
visited := make(map[common.TypeId]struct{})
for valueType.Named != nil {
typeId := valueType.Named.TypeId
if _, ok := visited[typeId]; ok {
// Guard against a self-referential alias chain.
return false
}
visited[typeId] = struct{}{}
typeDeclaration, ok := types[typeId]
if !ok {
return false
}
if typeDeclaration.Shape.Alias == nil {
return typeDeclaration.Shape.Enum != nil
}
valueType = typeDeclaration.Shape.Alias.AliasOf
}
return false
}

// isPrimitiveInteger returns true if the given primitive type is an integer.
func isPrimitiveInteger(primitive *ir.PrimitiveType) bool {
return primitive.V1 == common.PrimitiveTypeV1Integer || primitive.V1 == common.PrimitiveTypeV1Uint || primitive.V1 == common.PrimitiveTypeV1Uint64 || primitive.V1 == common.PrimitiveTypeV1Long
Expand Down
190 changes: 190 additions & 0 deletions generators/go/internal/generator/sdk_global_headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package generator

import (
"strings"
"testing"

"github.com/fern-api/fern-go/internal/coordinator"
"github.com/fern-api/fern-go/internal/fern/ir"
"github.com/fern-api/fern-go/internal/fern/ir/common"
)

// newGlobalHeaderTestWriter builds a bare fileWriter suitable for exercising the
// emitted core.RequestOptions definition.
func newGlobalHeaderTestWriter(types map[common.TypeId]*ir.TypeDeclaration) *fileWriter {
return newFileWriter(
"request_option.go",
"core",
"github.com/acme/test",
false, // whitelabel
false, // alwaysSendRequiredProperties
false, // inlinePathParameters
false, // inlineFileProperties
false, // useReaderForBytesRequest
false, // gettersPassByValue
false, // dedupeUnionBaseProperties
true, // serverURLVariables
false, // exportAllRequestsAtRoot
false, // omitEmptyRequestWrappers
userAgentConfig{},
UnionVersionUnspecified,
"",
types,
nil,
(*coordinator.Client)(nil),
)
}

// newGlobalHeaderForTest builds an IR global header with the given wire value,
// Go field name, and type.
func newGlobalHeaderForTest(wireValue string, fieldName string, valueType *ir.TypeReference) *ir.HttpHeader {
return &ir.HttpHeader{
Name: &common.NameAndWireValue{
WireValue: wireValue,
Name: &common.Name{
OriginalName: wireValue,
CamelCase: &common.SafeAndUnsafeString{UnsafeName: fieldName, SafeName: fieldName},
PascalCase: &common.SafeAndUnsafeString{UnsafeName: fieldName, SafeName: fieldName},
},
},
ValueType: valueType,
}
}

func newPrimitiveTypeReferenceForTest(primitive common.PrimitiveTypeV1) *ir.TypeReference {
return &ir.TypeReference{
Type: "primitive",
Primitive: &ir.PrimitiveType{V1: primitive},
}
}

func newOptionalTypeReferenceForTest(valueType *ir.TypeReference) *ir.TypeReference {
return &ir.TypeReference{
Type: "container",
Container: &ir.ContainerType{
Type: "optional",
Optional: valueType,
},
}
}

func newNamedTypeReferenceForTest(typeId string, name string) *ir.TypeReference {
return &ir.TypeReference{
Type: "named",
Named: &ir.NamedType{
TypeId: common.TypeId(typeId),
FernFilepath: &common.FernFilepath{},
Name: &common.Name{
OriginalName: name,
CamelCase: &common.SafeAndUnsafeString{UnsafeName: name, SafeName: name},
PascalCase: &common.SafeAndUnsafeString{UnsafeName: name, SafeName: name},
},
},
}
}

// requestOptionsSourceForHeaders emits core/request_option.go for an API whose
// only configuration is the given global headers.
func requestOptionsSourceForHeaders(t *testing.T, headers []*ir.HttpHeader, types map[common.TypeId]*ir.TypeDeclaration) string {
t.Helper()
f := newGlobalHeaderTestWriter(types)
if err := f.WriteRequestOptionsDefinition(
&ir.ApiAuth{},
headers,
nil, // idempotencyHeaders
&ir.SdkConfig{}, // sdkConfig
&ModuleConfig{}, // moduleConfig
"", // sdkVersion
nil, // environmentsConfig
nil, // inferredParams
); err != nil {
t.Fatalf("WriteRequestOptionsDefinition returned error: %v", err)
}
return f.buffer.String()
}

// TestGlobalHeadersAreOmittedWhenUnset asserts that global headers are only sent
// when they hold a value, matching the auth scheme header behavior. Previously
// every required global header was set unconditionally, so an SDK configured
// with just one of several global headers sent the rest as empty strings.
func TestGlobalHeadersAreOmittedWhenUnset(t *testing.T) {
enumTypeId := common.TypeId("type_commons:Version")
types := map[common.TypeId]*ir.TypeDeclaration{
enumTypeId: {
Shape: &ir.Type{
Type: "enum",
Enum: &ir.EnumTypeDeclaration{},
},
},
}
src := requestOptionsSourceForHeaders(
t,
[]*ir.HttpHeader{
newGlobalHeaderForTest("PLAID-CLIENT-ID", "ClientId", newPrimitiveTypeReferenceForTest(common.PrimitiveTypeV1String)),
newGlobalHeaderForTest("X-API-Count", "Count", newPrimitiveTypeReferenceForTest(common.PrimitiveTypeV1Integer)),
newGlobalHeaderForTest("X-API-Enabled", "Enabled", newPrimitiveTypeReferenceForTest(common.PrimitiveTypeV1Boolean)),
newGlobalHeaderForTest("X-API-Datetime", "Datetime", newPrimitiveTypeReferenceForTest(common.PrimitiveTypeV1DateTime)),
newGlobalHeaderForTest("X-API-Uuid", "Uuid", newPrimitiveTypeReferenceForTest(common.PrimitiveTypeV1Uuid)),
newGlobalHeaderForTest("X-API-Version", "Version", newNamedTypeReferenceForTest(string(enumTypeId), "Version")),
newGlobalHeaderForTest("X-API-Optional-Name", "OptionalName", newOptionalTypeReferenceForTest(newPrimitiveTypeReferenceForTest(common.PrimitiveTypeV1String))),
},
types,
)

for _, want := range []string{
`if r.ClientId != "" {`,
"if !r.Datetime.IsZero() {",
"if r.Uuid != uuid.Nil {",
`if r.Version != "" {`,
"if r.OptionalName != nil {",
} {
if !strings.Contains(src, want) {
t.Errorf("emitted request options missing %q\n---\n%s", want, src)
}
}

// false and 0 are meaningful wire values that cannot be distinguished from an
// unset field, so boolean and numeric headers are still always sent.
for _, want := range []string{
`header.Set("X-API-Count", fmt.Sprintf("%v", r.Count))`,
`header.Set("X-API-Enabled", fmt.Sprintf("%v", r.Enabled))`,
} {
if !strings.Contains(src, want) {
t.Errorf("emitted request options missing unguarded %q\n---\n%s", want, src)
}
}
for _, unwanted := range []string{"if r.Count", "if r.Enabled"} {
if strings.Contains(src, unwanted) {
t.Errorf("header with a meaningful zero value must not be guarded: %q\n---\n%s", unwanted, src)
}
}
}

// TestGlobalHeadersWithUncomparableTypesAreUnguarded asserts that headers whose
// generated Go type cannot be compared to a zero value (lists, maps, objects)
// keep the previous, unguarded behavior so that the generated code compiles.
func TestGlobalHeadersWithUncomparableTypesAreUnguarded(t *testing.T) {
objectTypeId := common.TypeId("type_commons:Metadata")
types := map[common.TypeId]*ir.TypeDeclaration{
objectTypeId: {
Shape: &ir.Type{
Type: "object",
Object: &ir.ObjectTypeDeclaration{},
},
},
}
src := requestOptionsSourceForHeaders(
t,
[]*ir.HttpHeader{
newGlobalHeaderForTest("X-API-Metadata", "Metadata", newNamedTypeReferenceForTest(string(objectTypeId), "Metadata")),
},
types,
)

if !strings.Contains(src, `header.Set("X-API-Metadata", fmt.Sprintf("%v", r.Metadata))`) {
t.Errorf("expected unguarded header.Set for uncomparable header type:\n---\n%s", src)
}
if strings.Contains(src, "if r.Metadata !=") {
t.Errorf("uncomparable header type must not be compared against a zero value:\n---\n%s", src)
}
}
12 changes: 12 additions & 0 deletions generators/go/sdk/changes/1.57.3/omit-unset-global-headers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Omit global headers (declared under `api.headers`) from requests when they are
left unset, instead of sending them with an empty value. This matches the
behavior of auth scheme headers, which were already guarded. This applies to
string, UUID, bytes, date, datetime, and enum headers; boolean and numeric
headers are unchanged, since `false` and `0` are meaningful values that
cannot be distinguished from an unset field. Unset UUID, date, and datetime
headers are no longer sent as `00000000-0000-0000-0000-000000000000` and
`0001-01-01` either.
type: fix
14 changes: 14 additions & 0 deletions generators/go/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 1.57.3
changelogEntry:
- summary: |
Omit global headers (declared under `api.headers`) from requests when they are
left unset, instead of sending them with an empty value. This matches the
behavior of auth scheme headers, which were already guarded. This applies to
string, UUID, bytes, date, datetime, and enum headers; boolean and numeric
headers are unchanged, since `false` and `0` are meaningful values that
cannot be distinguished from an unset field. Unset UUID, date, and datetime
headers are no longer sent as `00000000-0000-0000-0000-000000000000` and
`0001-01-01` either.
type: fix
createdAt: "2026-08-19"
irVersion: 67
- version: 1.57.2
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { RawSchemas } from "@fern-api/fern-definition-schema";
import { RelativeFilePath } from "@fern-api/path-utils";

import { FernDefinitionBuilderImpl } from "../FernDefinitionBuilder.js";

const FILE = RelativeFilePath.of("errors.yml");

function addErrors(schemas: RawSchemas.ErrorDeclarationSchema[]): RawSchemas.ErrorDeclarationSchema | undefined {
const builder = new FernDefinitionBuilderImpl(false);
for (const schema of schemas) {
builder.addError(FILE, { name: "BadRequestError", schema });
}
return builder.build().definitionFiles[FILE]?.errors?.["BadRequestError"];
}

describe("FernDefinitionBuilder.addError", () => {
it("keeps the named type when every endpoint declares the same error body", () => {
expect(
addErrors([
{ "status-code": 400, type: "Error" },
{ "status-code": 400, type: "Error" }
])
).toEqual({
"status-code": 400,
type: "Error"
});
});

// A shared error is decoded with a single type across every endpoint that declares it, so a
// body type only observed on one endpoint must not be claimed for the others: the endpoints
// that return something else would fail to decode and lose their specific error type at
// runtime.
it("falls back to unknown when another endpoint declares the same error without a body", () => {
expect(addErrors([{ "status-code": 400, type: "Error" }, { "status-code": 400 }])).toEqual({
"status-code": 400,
type: "unknown"
});
});

it("falls back to unknown when another endpoint declares the same error as unknown", () => {
expect(
addErrors([
{ "status-code": 400, type: "unknown" },
{ "status-code": 400, type: "Error" }
])
).toEqual({
"status-code": 400,
type: "unknown"
});
});

it("falls back to unknown when two endpoints declare conflicting error bodies", () => {
expect(
addErrors([
{ "status-code": 400, type: "Error" },
{ "status-code": 400, type: "OtherError" }
])
).toEqual({
"status-code": 400,
type: "unknown"
});
});

it("stays unknown once a conflict was detected, regardless of declaration order", () => {
expect(
addErrors([
{ "status-code": 400, type: "Error" },
{ "status-code": 400, type: "OtherError" },
{ "status-code": 400, type: "Error" }
])
).toEqual({
"status-code": 400,
type: "unknown"
});
});

it("keeps unknown when no endpoint declares an error body type", () => {
expect(addErrors([{ "status-code": 400, type: "unknown" }, { "status-code": 400 }])).toEqual({
"status-code": 400,
type: "unknown"
});
});
});
Loading
Loading