diff --git a/generators/go/internal/generator/sdk.go b/generators/go/internal/generator/sdk.go index 90f8aa5b5e9b..06c33a6783e3 100644 --- a/generators/go/internal/generator/sdk.go +++ b/generators/go/internal/generator/sdk.go @@ -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, "))") } } @@ -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 diff --git a/generators/go/internal/generator/sdk_global_headers_test.go b/generators/go/internal/generator/sdk_global_headers_test.go new file mode 100644 index 000000000000..0f0e8fa153b8 --- /dev/null +++ b/generators/go/internal/generator/sdk_global_headers_test.go @@ -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) + } +} diff --git a/generators/go/sdk/changes/1.57.3/omit-unset-global-headers.yml b/generators/go/sdk/changes/1.57.3/omit-unset-global-headers.yml new file mode 100644 index 000000000000..bd6394d7dbf2 --- /dev/null +++ b/generators/go/sdk/changes/1.57.3/omit-unset-global-headers.yml @@ -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 diff --git a/generators/go/sdk/versions.yml b/generators/go/sdk/versions.yml index b396bdcdc319..2b7ef5d4a6cd 100644 --- a/generators/go/sdk/versions.yml +++ b/generators/go/sdk/versions.yml @@ -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: | diff --git a/packages/cli/api-importers/commons/src/__test__/FernDefinitionBuilder.test.ts b/packages/cli/api-importers/commons/src/__test__/FernDefinitionBuilder.test.ts new file mode 100644 index 000000000000..c0dec3cf2524 --- /dev/null +++ b/packages/cli/api-importers/commons/src/__test__/FernDefinitionBuilder.test.ts @@ -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" + }); + }); +}); diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/nullable.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/nullable.json index 2be872846dbc..e6ab9db9b138 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/nullable.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi-ir/nullable.json @@ -55,7 +55,37 @@ "statusCode": 200, "type": "json" }, - "errors": {}, + "errors": { + "400": { + "generatedName": "BadRequestError", + "schema": { + "generatedName": "BadRequestErrorBody", + "value": { + "generatedName": "BadRequestErrorBody", + "schema": "UserError", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "type": "nullable" + }, + "description": "Bad request", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "examples": [ + { + "example": { + "properties": {}, + "type": "object" + } + } + ] + } + }, "servers": [], "authed": false, "method": "POST", @@ -397,7 +427,37 @@ "statusCode": 200, "type": "json" }, - "errors": {}, + "errors": { + "400": { + "generatedName": "BadRequestError", + "schema": { + "generatedName": "BadRequestErrorBody", + "value": { + "generatedName": "BadRequestErrorBody", + "schema": "UserError", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "reference" + }, + "type": "nullable" + }, + "description": "Bad request", + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "examples": [ + { + "example": { + "properties": {}, + "type": "object" + } + } + ] + } + }, "servers": [], "authed": false, "method": "GET", @@ -884,6 +944,44 @@ }, "type": "object" }, + "UserError": { + "generatedName": "UserError", + "value": { + "allOf": [], + "properties": [ + { + "conflict": {}, + "generatedName": "userErrorErrorCode", + "key": "errorCode", + "schema": { + "generatedName": "UserErrorErrorCode", + "value": { + "schema": { + "type": "string" + }, + "generatedName": "UserErrorErrorCode", + "groupName": [], + "type": "primitive" + }, + "groupName": [], + "type": "optional" + }, + "audiences": [] + } + ], + "allOfPropertyConflicts": [], + "generatedName": "UserError", + "groupName": [], + "additionalProperties": false, + "source": { + "file": "../openapi.yml", + "type": "openapi" + }, + "type": "object" + }, + "groupName": [], + "type": "nullable" + }, "UserStats": { "allOf": [], "properties": [ diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/nullable.json b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/nullable.json index 23870729f74a..a59784605d8e 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/nullable.json +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/__snapshots__/openapi/nullable.json @@ -5,6 +5,20 @@ "__package__.yml": { "absoluteFilepath": "/DUMMY_PATH", "contents": { + "errors": { + "BadRequestError": { + "docs": "Bad request", + "examples": [ + { + "docs": undefined, + "name": undefined, + "value": {}, + }, + ], + "status-code": 400, + "type": "nullable", + }, + }, "service": { "auth": false, "base-path": "", @@ -13,6 +27,9 @@ "auth": undefined, "display-name": "Create a user", "docs": undefined, + "errors": [ + "BadRequestError", + ], "examples": [ { "request": {}, @@ -66,6 +83,9 @@ "auth": undefined, "display-name": "Get a user", "docs": undefined, + "errors": [ + "BadRequestError", + ], "examples": [ { "path-parameters": { @@ -190,6 +210,16 @@ "openapi": "../openapi.yml", }, }, + "UserError": { + "docs": undefined, + "inline": undefined, + "properties": { + "errorCode": "optional", + }, + "source": { + "openapi": "../openapi.yml", + }, + }, "UserSettings": { "docs": undefined, "inline": undefined, @@ -227,7 +257,14 @@ }, }, }, - "rawContents": "service: + "rawContents": "errors: + BadRequestError: + status-code: 400 + type: nullable + docs: Bad request + examples: + - value: {} +service: auth: false base-path: '' endpoints: @@ -249,6 +286,8 @@ docs: User created successfully type: User status-code: 200 + errors: + - BadRequestError examples: - request: {} response: @@ -316,6 +355,8 @@ docs: User retrieved successfully type: User status-code: 200 + errors: + - BadRequestError examples: - path-parameters: userId: userId @@ -355,6 +396,11 @@ types: lastModified: optional> source: openapi: ../openapi.yml + UserError: + properties: + errorCode: optional + source: + openapi: ../openapi.yml UserStatsAccountStatus: enum: - active diff --git a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/nullable/openapi.yml b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/nullable/openapi.yml index 179a1ae889cf..a12c214df516 100644 --- a/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/nullable/openapi.yml +++ b/packages/cli/api-importers/openapi/openapi-ir-to-fern-tests/src/__test__/fixtures/nullable/openapi.yml @@ -20,6 +20,14 @@ paths: application/json: schema: $ref: "#/components/schemas/User" + # Shared with GET /users/{userId} below: a nullable named error schema must keep its + # named type instead of collapsing to unknown when the same error is declared twice. + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/UserError" put: summary: Update a user @@ -66,6 +74,12 @@ paths: application/json: schema: $ref: "#/components/schemas/User" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/UserError" components: schemas: @@ -123,6 +137,13 @@ components: format: date-time nullable: true + UserError: + type: object + nullable: true + properties: + errorCode: + type: string + UserStats: type: object properties: diff --git a/packages/cli/api-importers/openapi/openapi-ir/src/utils/__test__/isSchemaEqual.test.ts b/packages/cli/api-importers/openapi/openapi-ir/src/utils/__test__/isSchemaEqual.test.ts new file mode 100644 index 000000000000..686473f6a158 --- /dev/null +++ b/packages/cli/api-importers/openapi/openapi-ir/src/utils/__test__/isSchemaEqual.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { Schema, Source } from "../../index.js"; +import { isSchemaEqual } from "../isSchemaEqual.js"; + +function reference(schemaId: string): Schema { + return Schema.reference({ + schema: schemaId, + description: undefined, + availability: undefined, + generatedName: schemaId, + nameOverride: undefined, + title: undefined, + namespace: undefined, + groupName: undefined, + source: Source.openapi({ file: "openapi.yml" }) + }); +} + +function nullable(value: Schema): Schema { + return Schema.nullable({ + value, + description: undefined, + availability: undefined, + generatedName: "nullable", + nameOverride: undefined, + title: undefined, + namespace: undefined, + groupName: undefined, + inline: undefined + }); +} + +describe("isSchemaEqual", () => { + it("compares nullable schemas by their inner value", () => { + expect(isSchemaEqual(nullable(reference("MyError")), nullable(reference("MyError")))).toBe(true); + expect(isSchemaEqual(nullable(reference("MyError")), nullable(reference("OtherError")))).toBe(false); + }); + + it("does not treat a nullable schema as equal to its unwrapped value", () => { + expect(isSchemaEqual(nullable(reference("MyError")), reference("MyError"))).toBe(false); + }); +}); diff --git a/packages/cli/api-importers/openapi/openapi-ir/src/utils/isSchemaEqual.ts b/packages/cli/api-importers/openapi/openapi-ir/src/utils/isSchemaEqual.ts index b8ed5637e5a0..3be3636bcbff 100644 --- a/packages/cli/api-importers/openapi/openapi-ir/src/utils/isSchemaEqual.ts +++ b/packages/cli/api-importers/openapi/openapi-ir/src/utils/isSchemaEqual.ts @@ -14,6 +14,8 @@ export function isSchemaEqual(a: Schema, b: Schema): boolean { return a.schema === b.schema; } else if (a.type === "optional" && b.type === "optional") { return isSchemaEqual(a.value, b.value); + } else if (a.type === "nullable" && b.type === "nullable") { + return isSchemaEqual(a.value, b.value); } else if (a.type === "oneOf" && b.type === "oneOf") { return isOneOfEqual(a.value, b.value); } else if (a.type === "object" && b.type === "object") { diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/errors-mixed-typed-and-bodyless.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/errors-mixed-typed-and-bodyless.json new file mode 100644 index 000000000000..e15af3ec939e --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/baseline-sdks/errors-mixed-typed-and-bodyless.json @@ -0,0 +1,630 @@ +{ + "selfHosted": false, + "specVersion": "1.0.0", + "apiName": "api", + "apiDisplayName": "Test API", + "auth": { + "requirement": "ALL", + "schemes": [] + }, + "headers": [], + "idempotencyHeaders": [], + "types": { + "type_:Error": { + "name": { + "name": "Error", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "typeId": "type_:Error" + }, + "shape": { + "extends": [], + "properties": [ + { + "name": "error_code", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + }, + { + "name": "error_type", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "extraProperties": false, + "extendedProperties": [], + "type": "object" + }, + "referencedTypes": {}, + "encoding": { + "json": {} + }, + "userProvidedExamples": [], + "autogeneratedExamples": [] + } + }, + "errors": { + "error_:BadRequestError": { + "name": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "error_:BadRequestError" + }, + "discriminantValue": "BadRequestError", + "docs": "Bad Request", + "statusCode": 400, + "type": { + "type": "unknown" + }, + "examples": [ + { + "jsonExample": { + "error_code": "error_code" + }, + "shape": { + "shape": { + "unknown": { + "error_code": "error_code" + }, + "type": "unknown" + }, + "jsonExample": { + "error_code": "error_code" + } + } + } + ], + "headers": [] + } + }, + "services": { + "service_": { + "name": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "encoding": { + "json": {} + }, + "transport": { + "type": "http" + }, + "endpoints": [ + { + "id": "endpoint_.list", + "name": "list", + "auth": false, + "idempotent": false, + "method": "GET", + "path": { + "head": "/trees", + "parts": [] + }, + "fullPath": { + "head": "trees", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "response": { + "body": { + "value": { + "docs": "A response", + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "A response" + }, + "errors": [ + { + "error": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "error_:BadRequestError" + } + } + ], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + }, + { + "id": "endpoint_.get", + "name": "get", + "auth": false, + "idempotent": false, + "method": "GET", + "path": { + "head": "/trees/", + "parts": [ + { + "pathParameter": "id", + "tail": "" + } + ] + }, + "fullPath": { + "head": "trees/", + "parts": [ + { + "pathParameter": "id", + "tail": "" + } + ] + }, + "pathParameters": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "location": "ENDPOINT", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "allPathParameters": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "location": "ENDPOINT", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + } + } + ], + "queryParameters": [], + "headers": [], + "sdkRequest": { + "shape": { + "wrapperName": "GetRequest", + "bodyKey": "body", + "includePathParameters": true, + "onlyPathParameters": true, + "type": "wrapper" + }, + "requestParameterName": "request" + }, + "response": { + "body": { + "value": { + "docs": "A response", + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "type": "string" + } + }, + "type": "primitive" + }, + "type": "response" + }, + "type": "json" + }, + "statusCode": 200, + "docs": "A response" + }, + "errors": [ + { + "error": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "error_:BadRequestError" + } + } + ], + "userSpecifiedExamples": [], + "autogeneratedExamples": [], + "responseHeaders": [] + } + ] + } + }, + "constants": { + "errorInstanceIdKey": "errorInstanceId" + }, + "environments": { + "defaultEnvironment": "Default", + "environments": { + "environments": [ + { + "id": "Default", + "name": "Default", + "url": "https://api.test.com" + } + ], + "type": "singleBaseUrl" + } + }, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "pathParameters": [], + "variables": [], + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": {}, + "sharedTypes": [ + "type_:Error" + ] + }, + "webhookGroups": {}, + "websocketChannels": {}, + "dynamic": { + "version": "1.0.0", + "types": { + "type_:Error": { + "declaration": { + "name": { + "originalName": "Error", + "camelCase": { + "unsafeName": "error", + "safeName": "error" + }, + "snakeCase": { + "unsafeName": "error", + "safeName": "error" + }, + "screamingSnakeCase": { + "unsafeName": "ERROR", + "safeName": "ERROR" + }, + "pascalCase": { + "unsafeName": "Error", + "safeName": "Error" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "properties": [ + { + "name": { + "wireValue": "error_code", + "name": { + "originalName": "error_code", + "camelCase": { + "unsafeName": "errorCode", + "safeName": "errorCode" + }, + "snakeCase": { + "unsafeName": "error_code", + "safeName": "error_code" + }, + "screamingSnakeCase": { + "unsafeName": "ERROR_CODE", + "safeName": "ERROR_CODE" + }, + "pascalCase": { + "unsafeName": "ErrorCode", + "safeName": "ErrorCode" + } + } + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + }, + { + "name": { + "wireValue": "error_type", + "name": { + "originalName": "error_type", + "camelCase": { + "unsafeName": "errorType", + "safeName": "errorType" + }, + "snakeCase": { + "unsafeName": "error_type", + "safeName": "error_type" + }, + "screamingSnakeCase": { + "unsafeName": "ERROR_TYPE", + "safeName": "ERROR_TYPE" + }, + "pascalCase": { + "unsafeName": "ErrorType", + "safeName": "ErrorType" + } + } + }, + "typeReference": { + "value": { + "value": "STRING", + "type": "primitive" + }, + "type": "optional" + } + } + ], + "additionalProperties": false, + "type": "object" + } + }, + "headers": [], + "endpoints": { + "endpoint_.list": { + "declaration": { + "name": { + "originalName": "list", + "camelCase": { + "unsafeName": "list", + "safeName": "list" + }, + "snakeCase": { + "unsafeName": "list", + "safeName": "list" + }, + "screamingSnakeCase": { + "unsafeName": "LIST", + "safeName": "LIST" + }, + "pascalCase": { + "unsafeName": "List", + "safeName": "List" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "GET", + "path": "/trees" + }, + "request": { + "pathParameters": [], + "type": "body" + }, + "response": { + "type": "json" + }, + "examples": [] + }, + "endpoint_.get": { + "declaration": { + "name": { + "originalName": "get", + "camelCase": { + "unsafeName": "get", + "safeName": "get" + }, + "snakeCase": { + "unsafeName": "get", + "safeName": "get" + }, + "screamingSnakeCase": { + "unsafeName": "GET", + "safeName": "GET" + }, + "pascalCase": { + "unsafeName": "Get", + "safeName": "Get" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "location": { + "method": "GET", + "path": "/trees/{id}" + }, + "request": { + "declaration": { + "name": { + "originalName": "GetRequest", + "camelCase": { + "unsafeName": "getRequest", + "safeName": "getRequest" + }, + "snakeCase": { + "unsafeName": "get_request", + "safeName": "get_request" + }, + "screamingSnakeCase": { + "unsafeName": "GET_REQUEST", + "safeName": "GET_REQUEST" + }, + "pascalCase": { + "unsafeName": "GetRequest", + "safeName": "GetRequest" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "pathParameters": [ + { + "name": { + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + }, + "wireValue": "id" + }, + "typeReference": { + "value": "STRING", + "type": "primitive" + } + } + ], + "queryParameters": [], + "headers": [], + "metadata": { + "includePathParameters": true, + "onlyPathParameters": true + }, + "type": "inlined" + }, + "response": { + "type": "json" + }, + "examples": [] + } + }, + "pathParameters": [], + "environments": { + "defaultEnvironment": "Default", + "environments": { + "environments": [ + { + "id": "Default", + "name": { + "originalName": "Default", + "camelCase": { + "unsafeName": "default", + "safeName": "default" + }, + "snakeCase": { + "unsafeName": "default", + "safeName": "default" + }, + "screamingSnakeCase": { + "unsafeName": "DEFAULT", + "safeName": "DEFAULT" + }, + "pascalCase": { + "unsafeName": "Default", + "safeName": "Default" + } + }, + "url": "https://api.test.com" + } + ], + "type": "singleBaseUrl" + } + } + }, + "apiPlayground": true, + "casingsConfig": { + "smartCasing": true + }, + "subpackages": {}, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "service": "service_", + "types": [ + "type_:Error" + ], + "errors": [ + "error_:BadRequestError" + ], + "subpackages": [], + "hasEndpointsInTree": true, + "hasWebSocketInTree": false + }, + "sdkConfig": { + "isAuthMandatory": false, + "hasStreamingEndpoints": false, + "hasPaginatedEndpoints": false, + "hasFileDownloadEndpoints": false, + "platformHeaders": { + "language": "X-Fern-Language", + "sdkName": "X-Fern-SDK-Name", + "sdkVersion": "X-Fern-SDK-Version" + } + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/errors-mixed-typed-and-bodyless.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/errors-mixed-typed-and-bodyless.json new file mode 100644 index 000000000000..d9a9ad5bf387 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/__snapshots__/v3-sdks/errors-mixed-typed-and-bodyless.json @@ -0,0 +1,649 @@ +{ + "auth": { + "requirement": "ALL", + "schemes": [] + }, + "selfHosted": false, + "types": { + "Error": { + "name": { + "typeId": "Error", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "name": "Error" + }, + "shape": { + "properties": [ + { + "name": "error_code", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ErrorErrorCode_example_autogenerated": "string" + } + } + }, + { + "name": "error_type", + "valueType": { + "container": { + "optional": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "type": "optional" + }, + "type": "container" + }, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "ErrorErrorType_example_autogenerated": "string" + } + } + } + ], + "extends": [], + "extendedProperties": [], + "extraProperties": false, + "type": "object" + }, + "autogeneratedExamples": [], + "userProvidedExamples": [], + "referencedTypes": {}, + "inline": false, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "Error_example_autogenerated": { + "error_code": "string" + } + } + } + } + }, + "services": { + "service_": { + "name": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + } + }, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "endpoints": [ + { + "method": "GET", + "baseUrl": "Test API", + "path": { + "head": "/trees", + "parts": [] + }, + "pathParameters": [], + "queryParameters": [], + "headers": [], + "responseHeaders": [], + "errors": [ + { + "error": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "BadRequestError" + }, + "docs": "Bad Request" + } + ], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "d9186361", + "url": "/trees", + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "response": { + "value": { + "value": { + "jsonExample": "string", + "shape": { + "primitive": { + "string": { + "original": "string" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "type": "body" + }, + "type": "ok" + } + } + }, + { + "example": { + "id": "61552e02", + "url": "/trees", + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "response": { + "body": { + "jsonExample": { + "key": "value" + }, + "shape": { + "unknown": { + "key": "value" + }, + "type": "unknown" + } + }, + "error": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "BadRequestError" + }, + "type": "error" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/trees", + "parts": [] + }, + "allPathParameters": [], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.list", + "name": "list", + "v2RequestBodies": {}, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "A response", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "listExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "A response" + }, + "v2Examples": { + "autogeneratedExamples": { + "base_listExample_200": { + "displayName": "listExample", + "request": { + "endpoint": { + "method": "GET", + "path": "/trees" + }, + "environment": "Test API", + "pathParameters": {}, + "queryParameters": {}, + "headers": {} + }, + "response": { + "statusCode": 200, + "body": { + "value": "string", + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "A response", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "listExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "A response" + } + ] + } + }, + { + "method": "GET", + "baseUrl": "Test API", + "path": { + "head": "/trees/", + "parts": [ + { + "pathParameter": "id", + "tail": "" + } + ] + }, + "pathParameters": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "location": "ENDPOINT", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "id_example": "id" + } + } + } + ], + "queryParameters": [], + "headers": [], + "responseHeaders": [], + "errors": [ + { + "error": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "BadRequestError" + }, + "docs": "Bad Request" + } + ], + "auth": false, + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "fd306a5f", + "url": "/trees/id", + "endpointHeaders": [], + "endpointPathParameters": [ + { + "name": "id", + "value": { + "jsonExample": "id", + "shape": { + "primitive": { + "string": { + "original": "id" + }, + "type": "string" + }, + "type": "primitive" + } + } + } + ], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "response": { + "value": { + "value": { + "jsonExample": "string", + "shape": { + "primitive": { + "string": { + "original": "string" + }, + "type": "string" + }, + "type": "primitive" + } + }, + "type": "body" + }, + "type": "ok" + } + } + }, + { + "example": { + "id": "6ed1b7e0", + "url": "/trees/id", + "endpointHeaders": [], + "endpointPathParameters": [ + { + "name": "id", + "value": { + "jsonExample": "id", + "shape": { + "primitive": { + "string": { + "original": "id" + }, + "type": "string" + }, + "type": "primitive" + } + } + } + ], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "response": { + "body": { + "jsonExample": { + "key": "value" + }, + "shape": { + "unknown": { + "key": "value" + }, + "type": "unknown" + } + }, + "error": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "BadRequestError" + }, + "type": "error" + } + } + } + ], + "idempotent": false, + "fullPath": { + "head": "/trees/", + "parts": [ + { + "pathParameter": "id", + "tail": "" + } + ] + }, + "allPathParameters": [ + { + "name": "id", + "valueType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "location": "ENDPOINT", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "id_example": "id" + } + } + } + ], + "source": { + "type": "openapi" + }, + "audiences": [], + "id": "endpoint_.get", + "name": "get", + "v2RequestBodies": {}, + "response": { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "A response", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "getExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "A response" + }, + "v2Examples": { + "autogeneratedExamples": { + "base_getExample_200": { + "displayName": "getExample", + "request": { + "endpoint": { + "method": "GET", + "path": "/trees/id" + }, + "environment": "Test API", + "pathParameters": { + "id": "id" + }, + "queryParameters": {}, + "headers": {} + }, + "response": { + "statusCode": 200, + "body": { + "value": "string", + "type": "json" + } + } + } + }, + "userSpecifiedExamples": {} + }, + "v2Responses": { + "responses": [ + { + "statusCode": 200, + "body": { + "value": { + "responseBodyType": { + "primitive": { + "v1": "STRING", + "v2": { + "validation": {}, + "type": "string" + } + }, + "type": "primitive" + }, + "docs": "A response", + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": { + "getExample": "string" + } + }, + "type": "response" + }, + "type": "json" + }, + "docs": "A response" + } + ] + } + } + ] + } + }, + "errors": { + "BadRequestError": { + "name": { + "name": "BadRequestError", + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "errorId": "BadRequestError" + }, + "displayName": "BadRequestError", + "discriminantValue": { + "name": "BadRequestError", + "wireValue": "BadRequestError" + }, + "type": { + "type": "unknown" + }, + "statusCode": 400, + "docs": "Bad Request", + "examples": [], + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "headers": [] + } + }, + "webhookGroups": {}, + "headers": [], + "idempotencyHeaders": [], + "apiDisplayName": "Test API", + "pathParameters": [], + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "variables": [], + "serviceTypeReferenceInfo": { + "sharedTypes": [], + "typesReferencedOnlyByService": {} + }, + "environments": { + "defaultEnvironment": "Test API", + "environments": { + "environments": [ + { + "id": "Test API", + "name": "Test API", + "url": "https://api.test.com", + "docs": "Test API" + } + ], + "type": "singleBaseUrl" + } + }, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [] + }, + "service": "service_", + "types": [ + "Error" + ], + "errors": [], + "subpackages": [], + "hasEndpointsInTree": false + }, + "subpackages": {}, + "sdkConfig": { + "hasFileDownloadEndpoints": false, + "hasPaginatedEndpoints": false, + "hasStreamingEndpoints": false, + "isAuthMandatory": true, + "platformHeaders": { + "language": "", + "sdkName": "", + "sdkVersion": "" + } + }, + "apiName": "Test API", + "constants": { + "errorInstanceIdKey": "errorInstanceId" + } +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/fern/fern.config.json b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/fern/fern.config.json new file mode 100644 index 000000000000..7d8a700bd42b --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/fern/fern.config.json @@ -0,0 +1,4 @@ +{ + "organization": "fern", + "version": "*" +} \ No newline at end of file diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/fern/generators.yml b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/fern/generators.yml new file mode 100644 index 000000000000..5b01f1e0833d --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/fern/generators.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ../openapi.yml diff --git a/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/openapi.yml b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/openapi.yml new file mode 100644 index 000000000000..16a2df36ab77 --- /dev/null +++ b/packages/cli/api-importers/v3-importer-tests/src/__test__/fixtures/errors-mixed-typed-and-bodyless/openapi.yml @@ -0,0 +1,56 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Test API +servers: + - url: https://api.test.com + description: Test API +components: + schemas: + Error: + type: object + required: + - error_code + properties: + error_code: + type: string + error_type: + type: string + +paths: + /trees: + get: + operationId: list + responses: + "200": + description: A response + content: + application/json: + schema: + type: string + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /trees/{id}: + get: + operationId: get + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: A response + content: + application/json: + schema: + type: string + # This error has no body, so the shared 400 above cannot be typed: this endpoint's + # response would fail to decode and lose its specific error type at runtime. + "400": + description: Bad Request diff --git a/packages/cli/cli/changes/5.99.1/fix-nullable-error-bodies.yml b/packages/cli/cli/changes/5.99.1/fix-nullable-error-bodies.yml new file mode 100644 index 000000000000..12144c5409f6 --- /dev/null +++ b/packages/cli/cli/changes/5.99.1/fix-nullable-error-bodies.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Error responses that reference the same `nullable` schema from two or more endpoints now + generate a typed error body instead of an untyped one. Previously two responses referring + to the identical nullable schema were treated as conflicting shapes and the body was + downgraded to `unknown`. + + **This changes generated error-handling signatures for specs with that shape**, so those + SDKs may warrant a major bump. The trigger condition is narrow: two or more endpoints must + share a status code whose error schema is the same nullable schema. Errors declared by a + single endpoint, and shared errors whose schemas are not nullable, were already typed and + are unaffected. + + If your spec has that shape, error handling that indexes into the body must move to the + typed field: + + - Go fails to compile: `err.Body.(map[string]any)["error_code"]` becomes + `err.Body.ErrorCode`. + - Java fails to compile: casts such as `(Map) e.body()` no longer apply. + - **Python fails at runtime, not at build time**: the body is parsed into a model, so + `e.body["error_code"]` raises `TypeError: 'ErrorBody' object is not subscriptable` the + first time the error path fires. Use `e.body.error_code`. + - TypeScript is unaffected in practice, since the body was already `unknown` and had to be + narrowed. + type: fix diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index ec95b153413f..7aa898ebd838 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,32 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.99.1 + changelogEntry: + - summary: | + Error responses that reference the same `nullable` schema from two or more endpoints now + generate a typed error body instead of an untyped one. Previously two responses referring + to the identical nullable schema were treated as conflicting shapes and the body was + downgraded to `unknown`. + + **This changes generated error-handling signatures for specs with that shape**, so those + SDKs may warrant a major bump. The trigger condition is narrow: two or more endpoints must + share a status code whose error schema is the same nullable schema. Errors declared by a + single endpoint, and shared errors whose schemas are not nullable, were already typed and + are unaffected. + + If your spec has that shape, error handling that indexes into the body must move to the + typed field: + + - Go fails to compile: `err.Body.(map[string]any)["error_code"]` becomes + `err.Body.ErrorCode`. + - Java fails to compile: casts such as `(Map) e.body()` no longer apply. + - **Python fails at runtime, not at build time**: the body is parsed into a model, so + `e.body["error_code"]` raises `TypeError: 'ErrorBody' object is not subscriptable` the + first time the error path fires. Use `e.body.error_code`. + - TypeScript is unaffected in practice, since the body was already `unknown` and had to be + narrowed. + type: fix + createdAt: "2026-08-19" + irVersion: 67 - version: 5.99.0 changelogEntry: - summary: | diff --git a/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/go-global-headers.json b/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/go-global-headers.json new file mode 100644 index 000000000000..183367eabc69 --- /dev/null +++ b/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/go-global-headers.json @@ -0,0 +1,208 @@ +{ + "version": "1.0.0", + "types": {}, + "headers": [ + { + "name": { + "wireValue": "X-API-Client-Id", + "name": { + "originalName": "clientId", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + } + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "wireValue": "X-API-Version", + "name": { + "originalName": "version", + "camelCase": { + "unsafeName": "version", + "safeName": "version" + }, + "snakeCase": { + "unsafeName": "version", + "safeName": "version" + }, + "screamingSnakeCase": { + "unsafeName": "VERSION", + "safeName": "VERSION" + }, + "pascalCase": { + "unsafeName": "Version", + "safeName": "Version" + } + } + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "wireValue": "X-API-Tenant", + "name": { + "originalName": "tenant", + "camelCase": { + "unsafeName": "tenant", + "safeName": "tenant" + }, + "snakeCase": { + "unsafeName": "tenant", + "safeName": "tenant" + }, + "screamingSnakeCase": { + "unsafeName": "TENANT", + "safeName": "TENANT" + }, + "pascalCase": { + "unsafeName": "Tenant", + "safeName": "Tenant" + } + } + }, + "typeReference": { + "type": "optional", + "value": { + "type": "primitive", + "value": "STRING" + } + }, + "propertyAccess": null, + "variable": null + } + ], + "endpoints": { + "endpoint_service.get": { + "auth": { + "type": "bearer", + "token": { + "originalName": "apiKey", + "camelCase": { + "unsafeName": "apiKey", + "safeName": "apiKey" + }, + "snakeCase": { + "unsafeName": "api_key", + "safeName": "api_key" + }, + "screamingSnakeCase": { + "unsafeName": "API_KEY", + "safeName": "API_KEY" + }, + "pascalCase": { + "unsafeName": "APIKey", + "safeName": "APIKey" + } + } + }, + "declaration": { + "name": { + "originalName": "get", + "camelCase": { + "unsafeName": "get", + "safeName": "get" + }, + "snakeCase": { + "unsafeName": "get", + "safeName": "get" + }, + "screamingSnakeCase": { + "unsafeName": "GET", + "safeName": "GET" + }, + "pascalCase": { + "unsafeName": "Get", + "safeName": "Get" + } + }, + "fernFilepath": { + "allParts": [ + { + "originalName": "service", + "camelCase": { + "unsafeName": "service", + "safeName": "service" + }, + "snakeCase": { + "unsafeName": "service", + "safeName": "service" + }, + "screamingSnakeCase": { + "unsafeName": "SERVICE", + "safeName": "SERVICE" + }, + "pascalCase": { + "unsafeName": "Service", + "safeName": "Service" + } + } + ], + "packagePath": [], + "file": { + "originalName": "service", + "camelCase": { + "unsafeName": "service", + "safeName": "service" + }, + "snakeCase": { + "unsafeName": "service", + "safeName": "service" + }, + "screamingSnakeCase": { + "unsafeName": "SERVICE", + "safeName": "SERVICE" + }, + "pascalCase": { + "unsafeName": "Service", + "safeName": "Service" + } + } + } + }, + "location": { + "method": "GET", + "path": "/global-headers" + }, + "request": { + "type": "body", + "pathParameters": [], + "body": null, + "bodyRequired": null + }, + "response": { + "type": "json" + }, + "examples": null + } + }, + "pathParameters": [], + "environments": null, + "variables": null, + "globalParameters": null, + "generatorConfig": null +} \ No newline at end of file diff --git a/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/go-global-headers.json b/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/go-global-headers.json new file mode 100644 index 000000000000..02d3ba91d9a4 --- /dev/null +++ b/packages/cli/generation/ir-generator-tests/src/ir/__test__/test-definitions/go-global-headers.json @@ -0,0 +1,540 @@ +{ + "selfHosted": false, + "fdrApiDefinitionId": null, + "apiVersion": null, + "specVersion": null, + "apiName": "go-global-headers", + "apiDisplayName": null, + "apiDocs": null, + "auth": { + "requirement": "ALL", + "schemes": [ + { + "_type": "bearer", + "token": "apiKey", + "tokenEnvVar": "MY_API_KEY", + "tokenPlaceholder": null, + "key": "Bearer", + "docs": null + } + ], + "docs": null + }, + "headers": [ + { + "name": { + "wireValue": "X-API-Client-Id", + "name": "clientId" + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + }, + "env": null, + "clientDefault": null, + "defaultValue": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": null + }, + { + "name": { + "wireValue": "X-API-Version", + "name": "version" + }, + "valueType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + }, + "env": null, + "clientDefault": null, + "defaultValue": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": null + }, + { + "name": { + "wireValue": "X-API-Tenant", + "name": "tenant" + }, + "valueType": { + "_type": "container", + "container": { + "_type": "optional", + "optional": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + } + } + }, + "env": null, + "clientDefault": null, + "defaultValue": null, + "v2Examples": { + "userSpecifiedExamples": {}, + "autogeneratedExamples": {} + }, + "availability": null, + "docs": null + } + ], + "idempotencyHeaders": [], + "types": {}, + "errors": {}, + "services": { + "service_service": { + "availability": null, + "name": { + "fernFilepath": { + "allParts": [ + "service" + ], + "packagePath": [], + "file": "service" + } + }, + "displayName": null, + "basePath": { + "head": "", + "parts": [] + }, + "headers": [], + "pathParameters": [], + "encoding": { + "json": {}, + "proto": null + }, + "transport": { + "type": "http" + }, + "endpoints": [ + { + "id": "endpoint_service.get", + "name": "get", + "displayName": null, + "subtitle": null, + "auth": true, + "security": [ + { + "Bearer": [] + } + ], + "idempotent": false, + "baseUrl": null, + "v2BaseUrls": null, + "method": "GET", + "basePath": null, + "path": { + "head": "/global-headers", + "parts": [] + }, + "fullPath": { + "head": "global-headers", + "parts": [] + }, + "pathParameters": [], + "allPathParameters": [], + "queryParameters": [], + "headers": [], + "requestBody": null, + "v2RequestBodies": null, + "sdkRequest": null, + "response": { + "body": { + "type": "json", + "value": { + "type": "response", + "responseBodyType": { + "_type": "primitive", + "primitive": { + "v1": "STRING", + "v2": { + "type": "string", + "default": null, + "validation": null + } + } + }, + "docs": null, + "v2Examples": null + } + }, + "status-code": null, + "isWildcardStatusCode": null, + "docs": null + }, + "v2Responses": null, + "errors": [], + "userSpecifiedExamples": [], + "autogeneratedExamples": [ + { + "example": { + "id": "d9186361", + "url": "/global-headers", + "name": null, + "endpointHeaders": [], + "endpointPathParameters": [], + "queryParameters": [], + "servicePathParameters": [], + "serviceHeaders": [], + "rootPathParameters": [], + "request": null, + "response": { + "type": "ok", + "value": { + "type": "body", + "value": { + "shape": { + "type": "primitive", + "primitive": { + "type": "string", + "string": { + "original": "string" + } + } + }, + "jsonExample": "string" + } + } + }, + "docs": null + } + } + ], + "pagination": null, + "transport": null, + "v2Examples": null, + "source": null, + "audiences": null, + "retries": null, + "globalParameters": null, + "apiPlayground": null, + "responseHeaders": [], + "availability": null, + "docs": "GET request that sends the global headers" + } + ], + "audiences": null + } + }, + "constants": { + "errorInstanceIdKey": "errorInstanceId" + }, + "environments": null, + "errorDiscriminationStrategy": { + "type": "statusCode" + }, + "basePath": null, + "pathParameters": [], + "variables": [], + "globalParameters": null, + "serviceTypeReferenceInfo": { + "typesReferencedOnlyByService": {}, + "sharedTypes": [] + }, + "webhookGroups": {}, + "websocketChannels": {}, + "readmeConfig": null, + "sourceConfig": null, + "publishConfig": null, + "dynamic": { + "version": "1.0.0", + "types": {}, + "headers": [ + { + "name": { + "wireValue": "X-API-Client-Id", + "name": { + "originalName": "clientId", + "camelCase": { + "unsafeName": "clientID", + "safeName": "clientID" + }, + "snakeCase": { + "unsafeName": "client_id", + "safeName": "client_id" + }, + "screamingSnakeCase": { + "unsafeName": "CLIENT_ID", + "safeName": "CLIENT_ID" + }, + "pascalCase": { + "unsafeName": "ClientID", + "safeName": "ClientID" + } + } + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "wireValue": "X-API-Version", + "name": { + "originalName": "version", + "camelCase": { + "unsafeName": "version", + "safeName": "version" + }, + "snakeCase": { + "unsafeName": "version", + "safeName": "version" + }, + "screamingSnakeCase": { + "unsafeName": "VERSION", + "safeName": "VERSION" + }, + "pascalCase": { + "unsafeName": "Version", + "safeName": "Version" + } + } + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + }, + "propertyAccess": null, + "variable": null + }, + { + "name": { + "wireValue": "X-API-Tenant", + "name": { + "originalName": "tenant", + "camelCase": { + "unsafeName": "tenant", + "safeName": "tenant" + }, + "snakeCase": { + "unsafeName": "tenant", + "safeName": "tenant" + }, + "screamingSnakeCase": { + "unsafeName": "TENANT", + "safeName": "TENANT" + }, + "pascalCase": { + "unsafeName": "Tenant", + "safeName": "Tenant" + } + } + }, + "typeReference": { + "type": "optional", + "value": { + "type": "primitive", + "value": "STRING" + } + }, + "propertyAccess": null, + "variable": null + } + ], + "endpoints": { + "endpoint_service.get": { + "auth": { + "type": "bearer", + "token": { + "originalName": "apiKey", + "camelCase": { + "unsafeName": "apiKey", + "safeName": "apiKey" + }, + "snakeCase": { + "unsafeName": "api_key", + "safeName": "api_key" + }, + "screamingSnakeCase": { + "unsafeName": "API_KEY", + "safeName": "API_KEY" + }, + "pascalCase": { + "unsafeName": "APIKey", + "safeName": "APIKey" + } + } + }, + "declaration": { + "name": { + "originalName": "get", + "camelCase": { + "unsafeName": "get", + "safeName": "get" + }, + "snakeCase": { + "unsafeName": "get", + "safeName": "get" + }, + "screamingSnakeCase": { + "unsafeName": "GET", + "safeName": "GET" + }, + "pascalCase": { + "unsafeName": "Get", + "safeName": "Get" + } + }, + "fernFilepath": { + "allParts": [ + { + "originalName": "service", + "camelCase": { + "unsafeName": "service", + "safeName": "service" + }, + "snakeCase": { + "unsafeName": "service", + "safeName": "service" + }, + "screamingSnakeCase": { + "unsafeName": "SERVICE", + "safeName": "SERVICE" + }, + "pascalCase": { + "unsafeName": "Service", + "safeName": "Service" + } + } + ], + "packagePath": [], + "file": { + "originalName": "service", + "camelCase": { + "unsafeName": "service", + "safeName": "service" + }, + "snakeCase": { + "unsafeName": "service", + "safeName": "service" + }, + "screamingSnakeCase": { + "unsafeName": "SERVICE", + "safeName": "SERVICE" + }, + "pascalCase": { + "unsafeName": "Service", + "safeName": "Service" + } + } + } + }, + "location": { + "method": "GET", + "path": "/global-headers" + }, + "request": { + "type": "body", + "pathParameters": [], + "body": null, + "bodyRequired": null + }, + "response": { + "type": "json" + }, + "examples": null + } + }, + "pathParameters": [], + "environments": null, + "variables": null, + "globalParameters": null, + "generatorConfig": null + }, + "audiences": null, + "generationMetadata": null, + "apiPlayground": true, + "casingsConfig": { + "generationLanguage": null, + "keywords": null, + "smartCasing": true, + "smartCasingDigitWordBoundary": null + }, + "subpackages": { + "subpackage_service": { + "name": "service", + "displayName": null, + "fernFilepath": { + "allParts": [ + "service" + ], + "packagePath": [], + "file": "service" + }, + "service": "service_service", + "types": [], + "errors": [], + "subpackages": [], + "navigationConfig": null, + "webhooks": null, + "websocket": null, + "hasEndpointsInTree": true, + "hasWebSocketInTree": false, + "docs": null + } + }, + "rootPackage": { + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + }, + "websocket": null, + "service": null, + "types": [], + "errors": [], + "subpackages": [ + "subpackage_service" + ], + "webhooks": null, + "navigationConfig": null, + "hasEndpointsInTree": true, + "hasWebSocketInTree": false, + "docs": null + }, + "sdkConfig": { + "isAuthMandatory": true, + "hasStreamingEndpoints": false, + "hasPaginatedEndpoints": false, + "hasFileDownloadEndpoints": false, + "idempotencyKeyGeneration": null, + "platformHeaders": { + "language": "X-Fern-Language", + "sdkName": "X-Fern-SDK-Name", + "sdkVersion": "X-Fern-SDK-Version", + "userAgent": null + } + } +} \ No newline at end of file diff --git a/seed/go-sdk/go-global-headers/.fern/metadata.json b/seed/go-sdk/go-global-headers/.fern/metadata.json new file mode 100644 index 000000000000..d997f2713ce5 --- /dev/null +++ b/seed/go-sdk/go-global-headers/.fern/metadata.json @@ -0,0 +1,13 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-go-sdk", + "generatorVersion": "local", + "generatorConfig": { + "enableWireTests": false + }, + "originGitCommit": "DUMMY", + "invokedBy": "ci", + "requestedVersion": "0.0.1", + "ciProvider": "github", + "sdkVersion": "v0.0.1" +} diff --git a/seed/go-sdk/go-global-headers/.github/workflows/ci.yml b/seed/go-sdk/go-global-headers/.github/workflows/ci.yml new file mode 100644 index 000000000000..1097e6a18acc --- /dev/null +++ b/seed/go-sdk/go-global-headers/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: ci + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Compile + run: go build ./... + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.10.1 + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Setup wiremock server + run: | + PROJECT_NAME="wiremock-$(basename $(dirname $(pwd)) | tr -d '.')" + echo "PROJECT_NAME=$PROJECT_NAME" >> $GITHUB_ENV + if [ -f wiremock/docker-compose.test.yml ]; then + docker compose -p "$PROJECT_NAME" -f wiremock/docker-compose.test.yml down + docker compose -p "$PROJECT_NAME" -f wiremock/docker-compose.test.yml up -d + WIREMOCK_PORT=$(docker compose -p "$PROJECT_NAME" -f wiremock/docker-compose.test.yml port wiremock 8080 | cut -d: -f2) + echo "WIREMOCK_URL=http://localhost:$WIREMOCK_PORT" >> $GITHUB_ENV + fi + + - name: Test + run: go test ./... + + - name: Teardown wiremock server + if: always() + run: | + if [ -f wiremock/docker-compose.test.yml ]; then + docker compose -p "$PROJECT_NAME" -f wiremock/docker-compose.test.yml down + fi diff --git a/seed/go-sdk/go-global-headers/CONTRIBUTING.md b/seed/go-sdk/go-global-headers/CONTRIBUTING.md new file mode 100644 index 000000000000..cd327427f36b --- /dev/null +++ b/seed/go-sdk/go-global-headers/CONTRIBUTING.md @@ -0,0 +1,143 @@ +# Contributing + +Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project. + +## Getting Started + +### Prerequisites + +- Go 1.21+ +- Docker (only required to run wire tests; see [Wire Tests](#wire-tests)) + +### Installation + +Install the project dependencies: + +```bash +go mod tidy +``` + +### Building + +Build the project: + +```bash +go build ./... +``` + +### Testing + +Run the test suite: + +```bash +go test ./... +``` + +#### Wire Tests + +If this SDK includes a `wiremock/` directory, the test suite contains wire tests that exercise the client against a [WireMock](https://wiremock.org/) server. The `go test` command above expects `WIREMOCK_URL` to point at a running WireMock instance. + +Start WireMock, run the tests, then tear it down: + +```bash +docker compose -f wiremock/docker-compose.test.yml up -d +export WIREMOCK_URL=http://localhost:$(docker compose -f wiremock/docker-compose.test.yml port wiremock 8080 | cut -d: -f2) +go test ./... +docker compose -f wiremock/docker-compose.test.yml down +``` + +The compose file maps WireMock's container port `8080` to a random host port, which is why `WIREMOCK_URL` is derived from `docker compose ... port` rather than hardcoded. + +### Formatting + +Format code: + +```bash +gofmt -w . +``` + +Or equivalently: + +```bash +go fmt ./... +``` + +### Vetting + +Run the Go vet tool to catch common mistakes: + +```bash +go vet ./... +``` + +## About Generated Code + +**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated. + +### Generated Files + +The following directories contain generated code: +- Most Go files in the project + +### How to Customize + +If you need to customize the SDK, you have two options: + +#### Option 1: Use `.fernignore` + +For custom code that should persist across SDK regenerations: + +1. Create a `.fernignore` file in the project root +2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax) +3. Add your custom code to those files + +Files listed in `.fernignore` will not be overwritten when the SDK is regenerated. + +For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code). + +#### Option 2: Contribute to the Generator + +If you want to change how code is generated for all users of this SDK: + +1. The Go SDK generator lives in the [Fern repository](https://github.com/fern-api/fern) +2. Generator code is located at `generators/go-v2/` +3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md) +4. Submit a pull request with your changes to the generator + +This approach is best for: +- Bug fixes in generated code +- New features that would benefit all users +- Improvements to code generation patterns + +## Making Changes + +### Workflow + +1. Create a new branch for your changes +2. Make your modifications +3. Run tests to ensure nothing breaks: `go test ./...` +4. Format your code: `gofmt -w .` +5. Vet your code: `go vet ./...` +6. Build the project: `go build ./...` +7. Commit your changes with a clear commit message +8. Push your branch and create a pull request + +### Commit Messages + +Write clear, descriptive commit messages that explain what changed and why. + +### Code Style + +This project uses `gofmt` for code formatting. Run `gofmt -w .` before committing to ensure your code meets the project's style guidelines. + +## Questions or Issues? + +If you have questions or run into issues: + +1. Check the [Fern documentation](https://buildwithfern.com) +2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues) +3. Open a new issue if your question hasn't been addressed + +## License + +By contributing to this project, you agree that your contributions will be licensed under the same license as the project. diff --git a/seed/go-sdk/go-global-headers/README.md b/seed/go-sdk/go-global-headers/README.md new file mode 100644 index 000000000000..4c62979cc709 --- /dev/null +++ b/seed/go-sdk/go-global-headers/README.md @@ -0,0 +1,208 @@ +# Seed Go Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FGo) + +The Seed Go library provides convenient access to the Seed APIs from Go. + +## Table of Contents + +- [Reference](#reference) +- [Usage](#usage) +- [Environments](#environments) +- [Errors](#errors) +- [Request Options](#request-options) +- [Advanced](#advanced) + - [Response Headers](#response-headers) + - [Retries](#retries) + - [Timeouts](#timeouts) + - [Explicit Null](#explicit-null) +- [Contributing](#contributing) + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```go +package example + +import ( + context "context" + + client "github.com/go-global-headers/fern/client" + option "github.com/go-global-headers/fern/option" +) + +func do() { + client := client.NewClient( + option.WithAPIKey( + "", + ), + option.WithClientID( + "", + ), + option.WithVersion( + "", + ), + ) + client.Service.Get( + context.TODO(), + ) +} +``` + +## Environments + +You can choose between different environments by using the `option.WithBaseURL` option. You can configure any arbitrary base +URL, which is particularly useful in test environments. + +```go +client := client.NewClient( + option.WithBaseURL("https://example.com"), +) +``` + +## Errors + +Structured error types are returned from API calls that return non-success status codes. These errors are compatible +with the `errors.Is` and `errors.As` APIs, so you can access the error like so: + +```go +response, err := client.Service.Get(...) +if err != nil { + var apiError *core.APIError + if errors.As(err, apiError) { + // Do something with the API error ... + } + return err +} +``` + +## Request Options + +A variety of request options are included to adapt the behavior of the library, which includes configuring +authorization tokens, or providing your own instrumented `*http.Client`. + +These request options can either be +specified on the client so that they're applied on every request, or for an individual request, like so: + +> Providing your own `*http.Client` is recommended. Otherwise, the `http.DefaultClient` will be used, +> and your client will wait indefinitely for a response (unless the per-request, context-based timeout +> is used). + +```go +// Specify default options applied on every request. +client := client.NewClient( + option.WithApiKey(""), + option.WithHTTPClient( + &http.Client{ + Timeout: 5 * time.Second, + }, + ), +) + +// Specify options for an individual request. +response, err := client.Service.Get( + ..., + option.WithApiKey(""), +) +``` + +## Advanced + +### Response Headers + +You can access the raw HTTP response data by using the `WithRawResponse` field on the client. This is useful +when you need to examine the response headers received from the API call. (When the endpoint is paginated, +the raw HTTP response data will be included automatically in the Page response object.) + +```go +response, err := client.Service.WithRawResponse.Get(...) +if err != nil { + return err +} +fmt.Printf("Got response headers: %v", response.Header) +fmt.Printf("Got status code: %d", response.StatusCode) +``` + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +Which status codes are retried depends on the `retryStatusCodes` generator configuration: + +**`legacy`** (current default): retries on +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#server_error_responses) (All server errors, including 500) + +**`recommended`**: retries on +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [502](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) (Bad Gateway) +- [503](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) (Service Unavailable) +- [504](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504) (Gateway Timeout) + +If the `Retry-After` header is present in the response, the SDK will prioritize respecting its value exactly +over the default exponential backoff. + +Use the `option.WithMaxAttempts` option to configure this behavior for the entire client or an individual request: + +```go +client := client.NewClient( + option.WithMaxAttempts(1), +) + +response, err := client.Service.Get( + ..., + option.WithMaxAttempts(1), +) +``` + +### Timeouts + +Setting a timeout for each individual request is as simple as using the standard context library. Setting a one second timeout for an individual API call looks like the following: + +```go +ctx, cancel := context.WithTimeout(ctx, time.Second) +defer cancel() + +response, err := client.Service.Get(ctx, ...) +``` + +### Explicit Null + +If you want to send the explicit `null` JSON value through an optional parameter, you can use the setters\ +that come with every object. Calling a setter method for a property will flip a bit in the `explicitFields` +bitfield for that setter's object; during serialization, any property with a flipped bit will have its +omittable status stripped, so zero or `nil` values will be sent explicitly rather than omitted altogether: + +```go +type ExampleRequest struct { + // An optional string parameter. + Name *string `json:"name,omitempty" url:"-"` + + // Private bitmask of fields set to an explicit value and therefore not to be omitted + explicitFields *big.Int `json:"-" url:"-"` +} + +request := &ExampleRequest{} +request.SetName(nil) + +response, err := client.Service.Get(ctx, request, ...) +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! diff --git a/seed/go-sdk/go-global-headers/client/client.go b/seed/go-sdk/go-global-headers/client/client.go new file mode 100644 index 000000000000..ef275dbab6c6 --- /dev/null +++ b/seed/go-sdk/go-global-headers/client/client.go @@ -0,0 +1,39 @@ +// Code generated by Fern. DO NOT EDIT. + +package client + +import ( + os "os" + + core "github.com/go-global-headers/fern/core" + internal "github.com/go-global-headers/fern/internal" + option "github.com/go-global-headers/fern/option" + service "github.com/go-global-headers/fern/service" +) + +type Client struct { + Service *service.Client + + options *core.RequestOptions + baseURL string + caller *internal.Caller +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + if options.APIKey == "" { + options.APIKey = os.Getenv("MY_API_KEY") + } + return &Client{ + Service: service.NewClient(options), + options: options, + baseURL: options.BaseURL, + caller: internal.NewCaller( + &internal.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + }, + ), + } +} diff --git a/seed/go-sdk/go-global-headers/client/client_test.go b/seed/go-sdk/go-global-headers/client/client_test.go new file mode 100644 index 000000000000..e1ec674837a3 --- /dev/null +++ b/seed/go-sdk/go-global-headers/client/client_test.go @@ -0,0 +1,45 @@ +// Code generated by Fern. DO NOT EDIT. + +package client + +import ( + option "github.com/go-global-headers/fern/option" + assert "github.com/stretchr/testify/assert" + http "net/http" + testing "testing" + time "time" +) + +func TestNewClient(t *testing.T) { + t.Run("default", func(t *testing.T) { + c := NewClient() + assert.Empty(t, c.baseURL) + }) + + t.Run("base url", func(t *testing.T) { + c := NewClient( + option.WithBaseURL("test.co"), + ) + assert.Equal(t, "test.co", c.baseURL) + }) + + t.Run("http client", func(t *testing.T) { + httpClient := &http.Client{ + Timeout: 5 * time.Second, + } + c := NewClient( + option.WithHTTPClient(httpClient), + ) + assert.Empty(t, c.baseURL) + }) + + t.Run("http header", func(t *testing.T) { + header := make(http.Header) + header.Set("X-API-Tenancy", "test") + c := NewClient( + option.WithHTTPHeader(header), + ) + assert.Empty(t, c.baseURL) + assert.Equal(t, "test", c.options.HTTPHeader.Get("X-API-Tenancy")) + }) +} diff --git a/seed/go-sdk/go-global-headers/core/api_error.go b/seed/go-sdk/go-global-headers/core/api_error.go new file mode 100644 index 000000000000..6168388541b4 --- /dev/null +++ b/seed/go-sdk/go-global-headers/core/api_error.go @@ -0,0 +1,47 @@ +package core + +import ( + "fmt" + "net/http" +) + +// APIError is a lightweight wrapper around the standard error +// interface that preserves the status code from the RPC, if any. +type APIError struct { + err error + + StatusCode int `json:"-"` + Header http.Header `json:"-"` +} + +// NewAPIError constructs a new API error. +func NewAPIError(statusCode int, header http.Header, err error) *APIError { + return &APIError{ + err: err, + Header: header, + StatusCode: statusCode, + } +} + +// Unwrap returns the underlying error. This also makes the error compatible +// with errors.As and errors.Is. +func (a *APIError) Unwrap() error { + if a == nil { + return nil + } + return a.err +} + +// Error returns the API error's message. +func (a *APIError) Error() string { + if a == nil || (a.err == nil && a.StatusCode == 0) { + return "" + } + if a.err == nil { + return fmt.Sprintf("%d", a.StatusCode) + } + if a.StatusCode == 0 { + return a.err.Error() + } + return fmt.Sprintf("%d: %s", a.StatusCode, a.err.Error()) +} diff --git a/seed/go-sdk/go-global-headers/core/http.go b/seed/go-sdk/go-global-headers/core/http.go new file mode 100644 index 000000000000..92c435692940 --- /dev/null +++ b/seed/go-sdk/go-global-headers/core/http.go @@ -0,0 +1,15 @@ +package core + +import "net/http" + +// HTTPClient is an interface for a subset of the *http.Client. +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// Response is an HTTP response from an HTTP client. +type Response[T any] struct { + StatusCode int + Header http.Header + Body T +} diff --git a/seed/go-sdk/go-global-headers/core/request_option.go b/seed/go-sdk/go-global-headers/core/request_option.go new file mode 100644 index 000000000000..d31987062b2d --- /dev/null +++ b/seed/go-sdk/go-global-headers/core/request_option.go @@ -0,0 +1,215 @@ +// Code generated by Fern. DO NOT EDIT. + +package core + +import ( + fmt "fmt" + http "net/http" + url "net/url" +) + +// RequestOption adapts the behavior of the client or an individual request. +type RequestOption interface { + applyRequestOptions(*RequestOptions) +} + +// RequestOptions defines all of the possible request options. +// +// This type is primarily used by the generated code and is not meant +// to be used directly; use the option package instead. +type RequestOptions struct { + BaseURL string + HTTPClient HTTPClient + HTTPHeader http.Header + BodyProperties map[string]interface{} + QueryParameters url.Values + MaxAttempts uint + MaxBufSize int + MaxStreamReconnectAttempts uint + DisableStreamReconnection bool + DisableRetries bool + APIKey string + APIKeyFunc func() (string, error) + ClientID string + Version string + Tenant *string +} + +// NewRequestOptions returns a new *RequestOptions value. +// +// This function is primarily used by the generated code and is not meant +// to be used directly; use RequestOption instead. +func NewRequestOptions(opts ...RequestOption) *RequestOptions { + options := &RequestOptions{ + HTTPHeader: make(http.Header), + BodyProperties: make(map[string]interface{}), + QueryParameters: make(url.Values), + } + for _, opt := range opts { + opt.applyRequestOptions(options) + } + return options +} + +// ToHeader maps the configured request options into a http.Header used +// for the request(s). +func (r *RequestOptions) ToHeader() http.Header { + header := r.cloneHeader() + if r.APIKey != "" { + header.Set("Authorization", "Bearer "+r.APIKey) + } else if r.APIKeyFunc != nil { + if token, err := r.APIKeyFunc(); err == nil && token != "" { + header.Set("Authorization", "Bearer "+token) + } + } + if r.ClientID != "" { + header.Set("X-API-Client-Id", fmt.Sprintf("%v", r.ClientID)) + } + if r.Version != "" { + header.Set("X-API-Version", fmt.Sprintf("%v", r.Version)) + } + if r.Tenant != nil { + header.Set("X-API-Tenant", fmt.Sprintf("%v", *r.Tenant)) + } + return header +} + +func (r *RequestOptions) cloneHeader() http.Header { + headers := r.HTTPHeader.Clone() + headers.Set("X-Fern-Language", "Go") + headers.Set("X-Fern-SDK-Name", "github.com/go-global-headers/fern") + headers.Set("X-Fern-SDK-Version", "v0.0.1") + headers.Set("User-Agent", "github.com/go-global-headers/fern/0.0.1") + return headers +} + +// BaseURLOption implements the RequestOption interface. +type BaseURLOption struct { + BaseURL string +} + +func (b *BaseURLOption) applyRequestOptions(opts *RequestOptions) { + opts.BaseURL = b.BaseURL +} + +// HTTPClientOption implements the RequestOption interface. +type HTTPClientOption struct { + HTTPClient HTTPClient +} + +func (h *HTTPClientOption) applyRequestOptions(opts *RequestOptions) { + opts.HTTPClient = h.HTTPClient +} + +// HTTPHeaderOption implements the RequestOption interface. +type HTTPHeaderOption struct { + HTTPHeader http.Header +} + +func (h *HTTPHeaderOption) applyRequestOptions(opts *RequestOptions) { + opts.HTTPHeader = h.HTTPHeader +} + +// BodyPropertiesOption implements the RequestOption interface. +type BodyPropertiesOption struct { + BodyProperties map[string]interface{} +} + +func (b *BodyPropertiesOption) applyRequestOptions(opts *RequestOptions) { + opts.BodyProperties = b.BodyProperties +} + +// QueryParametersOption implements the RequestOption interface. +type QueryParametersOption struct { + QueryParameters url.Values +} + +func (q *QueryParametersOption) applyRequestOptions(opts *RequestOptions) { + opts.QueryParameters = q.QueryParameters +} + +// MaxAttemptsOption implements the RequestOption interface. +type MaxAttemptsOption struct { + MaxAttempts uint +} + +func (m *MaxAttemptsOption) applyRequestOptions(opts *RequestOptions) { + opts.MaxAttempts = m.MaxAttempts +} + +// MaxBufSizeOption implements the RequestOption interface. +type MaxBufSizeOption struct { + MaxBufSize int +} + +func (m *MaxBufSizeOption) applyRequestOptions(opts *RequestOptions) { + opts.MaxBufSize = m.MaxBufSize +} + +// MaxStreamReconnectAttemptsOption implements the RequestOption interface. +type MaxStreamReconnectAttemptsOption struct { + MaxStreamReconnectAttempts uint +} + +func (m *MaxStreamReconnectAttemptsOption) applyRequestOptions(opts *RequestOptions) { + opts.MaxStreamReconnectAttempts = m.MaxStreamReconnectAttempts +} + +// WithoutStreamReconnectionOption implements the RequestOption interface. +type WithoutStreamReconnectionOption struct{} + +func (w *WithoutStreamReconnectionOption) applyRequestOptions(opts *RequestOptions) { + opts.DisableStreamReconnection = true +} + +// WithoutRetriesOption implements the RequestOption interface. +type WithoutRetriesOption struct{} + +func (w *WithoutRetriesOption) applyRequestOptions(opts *RequestOptions) { + opts.DisableRetries = true +} + +// APIKeyOption implements the RequestOption interface. +type APIKeyOption struct { + APIKey string +} + +func (a *APIKeyOption) applyRequestOptions(opts *RequestOptions) { + opts.APIKey = a.APIKey +} + +// APIKeyFuncOption implements the RequestOption interface. +type APIKeyFuncOption struct { + APIKeyFunc func() (string, error) +} + +func (a *APIKeyFuncOption) applyRequestOptions(opts *RequestOptions) { + opts.APIKeyFunc = a.APIKeyFunc +} + +// ClientIDOption implements the RequestOption interface. +type ClientIDOption struct { + ClientID string +} + +func (c *ClientIDOption) applyRequestOptions(opts *RequestOptions) { + opts.ClientID = c.ClientID +} + +// VersionOption implements the RequestOption interface. +type VersionOption struct { + Version string +} + +func (v *VersionOption) applyRequestOptions(opts *RequestOptions) { + opts.Version = v.Version +} + +// TenantOption implements the RequestOption interface. +type TenantOption struct { + Tenant *string +} + +func (t *TenantOption) applyRequestOptions(opts *RequestOptions) { + opts.Tenant = t.Tenant +} diff --git a/seed/go-sdk/go-global-headers/dynamic-snippets/example0/snippet.go b/seed/go-sdk/go-global-headers/dynamic-snippets/example0/snippet.go new file mode 100644 index 000000000000..6da98be00905 --- /dev/null +++ b/seed/go-sdk/go-global-headers/dynamic-snippets/example0/snippet.go @@ -0,0 +1,28 @@ +package example + +import ( + context "context" + + client "github.com/go-global-headers/fern/client" + option "github.com/go-global-headers/fern/option" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + option.WithAPIKey( + "", + ), + option.WithClientID( + "", + ), + option.WithVersion( + "", + ), + ) + client.Service.Get( + context.TODO(), + ) +} diff --git a/seed/go-sdk/go-global-headers/error_codes.go b/seed/go-sdk/go-global-headers/error_codes.go new file mode 100644 index 000000000000..1a4fcce86fb6 --- /dev/null +++ b/seed/go-sdk/go-global-headers/error_codes.go @@ -0,0 +1,9 @@ +// Code generated by Fern. DO NOT EDIT. + +package goglobalheaders + +import ( + internal "github.com/go-global-headers/fern/internal" +) + +var ErrorCodes internal.ErrorCodes = internal.ErrorCodes{} diff --git a/seed/go-sdk/go-global-headers/file_param.go b/seed/go-sdk/go-global-headers/file_param.go new file mode 100644 index 000000000000..3f36e8e681c2 --- /dev/null +++ b/seed/go-sdk/go-global-headers/file_param.go @@ -0,0 +1,41 @@ +package goglobalheaders + +import ( + "io" +) + +// FileParam is a file type suitable for multipart/form-data uploads. +type FileParam struct { + io.Reader + filename string + contentType string +} + +// FileParamOption adapts the behavior of the FileParam. No options are +// implemented yet, but this interface allows for future extensibility. +type FileParamOption interface { + apply() +} + +// NewFileParam returns a *FileParam type suitable for multipart/form-data uploads. All file +// upload endpoints accept a simple io.Reader, which is usually created by opening a file +// via os.Open. +// +// However, some endpoints require additional metadata about the file such as a specific +// Content-Type or custom filename. FileParam makes it easier to create the correct type +// signature for these endpoints. +func NewFileParam( + reader io.Reader, + filename string, + contentType string, + opts ...FileParamOption, +) *FileParam { + return &FileParam{ + Reader: reader, + filename: filename, + contentType: contentType, + } +} + +func (f *FileParam) Name() string { return f.filename } +func (f *FileParam) ContentType() string { return f.contentType } diff --git a/seed/go-sdk/go-global-headers/go.mod b/seed/go-sdk/go-global-headers/go.mod new file mode 100644 index 000000000000..8c52ef3a10b8 --- /dev/null +++ b/seed/go-sdk/go-global-headers/go.mod @@ -0,0 +1,14 @@ +module github.com/go-global-headers/fern + +go 1.21 + +require github.com/google/uuid v1.6.0 + +require github.com/stretchr/testify v1.8.4 + +require gopkg.in/yaml.v3 v3.0.1 // indirect + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/seed/go-sdk/go-global-headers/go.sum b/seed/go-sdk/go-global-headers/go.sum new file mode 100644 index 000000000000..fcca6d128057 --- /dev/null +++ b/seed/go-sdk/go-global-headers/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/seed/go-sdk/go-global-headers/internal/caller.go b/seed/go-sdk/go-global-headers/internal/caller.go new file mode 100644 index 000000000000..f7837e6a7db9 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/caller.go @@ -0,0 +1,334 @@ +package internal + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/go-global-headers/fern/core" +) + +const ( + // contentType specifies the JSON Content-Type header value. + contentType = "application/json" + contentTypeHeader = "Content-Type" + contentTypeFormURLEncoded = "application/x-www-form-urlencoded" +) + +// Caller calls APIs and deserializes their response, if any. +type Caller struct { + client core.HTTPClient + retrier *Retrier +} + +// CallerParams represents the parameters used to constrcut a new *Caller. +type CallerParams struct { + Client core.HTTPClient + MaxAttempts uint + DisableRetries bool +} + +// NewCaller returns a new *Caller backed by the given parameters. +func NewCaller(params *CallerParams) *Caller { + var httpClient core.HTTPClient = http.DefaultClient + if params.Client != nil { + httpClient = params.Client + } + return &Caller{ + client: httpClient, + retrier: NewRetrier(buildRetryOptions(params.MaxAttempts, params.DisableRetries)...), + } +} + +// CallParams represents the parameters used to issue an API call. +type CallParams struct { + URL string + Method string + MaxAttempts uint + DisableRetries bool + Headers http.Header + BodyProperties map[string]interface{} + QueryParameters url.Values + Client core.HTTPClient + Request interface{} + Response interface{} + ResponseIsOptional bool + ErrorDecoder ErrorDecoder +} + +// CallResponse is a parsed HTTP response from an API call. +type CallResponse struct { + StatusCode int + Header http.Header +} + +// Call issues an API call according to the given call parameters. +func (c *Caller) Call(ctx context.Context, params *CallParams) (*CallResponse, error) { + url := buildURL(params.URL, params.QueryParameters) + req, err := newRequest( + ctx, + url, + params.Method, + params.Headers, + params.Request, + params.BodyProperties, + ) + if err != nil { + return nil, err + } + + // If the call has been cancelled, don't issue the request. + if err := ctx.Err(); err != nil { + return nil, err + } + + client := c.client + if params.Client != nil { + // Use the HTTP client scoped to the request. + client = params.Client + } + + resp, err := c.retrier.Run( + client.Do, + req, + params.ErrorDecoder, + buildRetryOptions(params.MaxAttempts, params.DisableRetries)..., + ) + if err != nil { + return nil, err + } + + // Close the response body after we're done. + defer func() { _ = resp.Body.Close() }() + + body, err := decompressedResponseBody(resp) + if err != nil { + return nil, err + } + + // Check if the call was cancelled before we return the error + // associated with the call and/or unmarshal the response data. + if err := ctx.Err(); err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, decodeError(resp, body, params.ErrorDecoder) + } + + // Mutate the response parameter in-place. + if params.Response != nil { + if writer, ok := params.Response.(io.Writer); ok { + _, err = io.Copy(writer, body) + } else { + err = json.NewDecoder(body).Decode(params.Response) + } + if err != nil { + if err == io.EOF { + if params.ResponseIsOptional { + // The response is optional, so we should ignore the + // io.EOF error + return &CallResponse{ + StatusCode: resp.StatusCode, + Header: resp.Header, + }, nil + } + return nil, fmt.Errorf("expected a %T response, but the server responded with nothing", params.Response) + } + return nil, err + } + } + + return &CallResponse{ + StatusCode: resp.StatusCode, + Header: resp.Header, + }, nil +} + +// buildURL constructs the final URL by appending the given query parameters (if any). +func buildURL( + url string, + queryParameters url.Values, +) string { + if len(queryParameters) == 0 { + return url + } + if strings.ContainsRune(url, '?') { + url += "&" + } else { + url += "?" + } + url += queryParameters.Encode() + return url +} + +// newRequest returns a new *http.Request with all of the fields +// required to issue the call. +func newRequest( + ctx context.Context, + url string, + method string, + endpointHeaders http.Header, + request interface{}, + bodyProperties map[string]interface{}, +) (*http.Request, error) { + // Determine the content type from headers, defaulting to JSON. + reqContentType := contentType + if endpointHeaders != nil { + if ct := endpointHeaders.Get(contentTypeHeader); ct != "" { + reqContentType = ct + } + } + requestBody, err := newRequestBody(request, bodyProperties, reqContentType) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, requestBody) + if err != nil { + return nil, err + } + req.Header.Set(contentTypeHeader, reqContentType) + for name, values := range endpointHeaders { + req.Header[name] = values + } + return req, nil +} + +// newRequestBody returns a new io.Reader that represents the HTTP request body. +func newRequestBody(request interface{}, bodyProperties map[string]interface{}, reqContentType string) (io.Reader, error) { + if isNil(request) { + if len(bodyProperties) == 0 { + return nil, nil + } + if reqContentType == contentTypeFormURLEncoded { + return newFormURLEncodedBody(bodyProperties), nil + } + requestBytes, err := json.Marshal(bodyProperties) + if err != nil { + return nil, err + } + return bytes.NewReader(requestBytes), nil + } + if body, ok := request.(io.Reader); ok { + return body, nil + } + // Handle form URL encoded content type. + if reqContentType == contentTypeFormURLEncoded { + return newFormURLEncodedRequestBody(request, bodyProperties) + } + requestBytes, err := MarshalJSONWithExtraProperties(request, bodyProperties) + if err != nil { + return nil, err + } + return bytes.NewReader(requestBytes), nil +} + +// newFormURLEncodedBody returns a new io.Reader that represents a form URL encoded body +// from the given body properties map. +func newFormURLEncodedBody(bodyProperties map[string]interface{}) io.Reader { + values := url.Values{} + for key, val := range bodyProperties { + values.Set(key, fmt.Sprintf("%v", val)) + } + return strings.NewReader(values.Encode()) +} + +// newFormURLEncodedRequestBody returns a new io.Reader that represents a form URL encoded body +// from the given request struct and body properties. +func newFormURLEncodedRequestBody(request interface{}, bodyProperties map[string]interface{}) (io.Reader, error) { + values := url.Values{} + // Marshal the request to JSON first to respect any custom MarshalJSON methods, + // then unmarshal into a map to extract the field values. + jsonBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + var jsonMap map[string]interface{} + if err := json.Unmarshal(jsonBytes, &jsonMap); err != nil { + return nil, err + } + // Convert the JSON map to form URL encoded values. + for key, val := range jsonMap { + if val == nil { + continue + } + values.Set(key, fmt.Sprintf("%v", val)) + } + // Add any extra body properties. + for key, val := range bodyProperties { + values.Set(key, fmt.Sprintf("%v", val)) + } + return strings.NewReader(values.Encode()), nil +} + +// decompressedResponseBody returns a reader for the response body, wrapped +// with a gzip reader when the server responds with a gzip-encoded body that +// the underlying HTTP client did not transparently decompress (e.g. when an +// Accept-Encoding header is set explicitly on the request). The response is +// not modified; closing resp.Body remains the caller's responsibility. +func decompressedResponseBody(resp *http.Response) (io.Reader, error) { + if resp.Uncompressed || resp.Body == nil || resp.Body == http.NoBody { + return resp.Body, nil + } + if !strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { + return resp.Body, nil + } + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + if err == io.EOF { + // The response body is empty, so there is nothing to decompress. + return http.NoBody, nil + } + return nil, err + } + return gzipReader, nil +} + +// decodeError decodes the error from the given HTTP response, reading the +// error content from the given body. Note that it's the caller's +// responsibility to close the response body. +func decodeError(response *http.Response, body io.Reader, errorDecoder ErrorDecoder) error { + if errorDecoder != nil { + // This endpoint has custom errors, so we'll + // attempt to unmarshal the error into a structured + // type based on the status code. + return errorDecoder(response.StatusCode, response.Header, body) + } + // This endpoint doesn't have any custom error + // types, so we just read the body as-is, and + // put it into a normal error. + bytes, err := io.ReadAll(body) + if err != nil && err != io.EOF { + return err + } + if err == io.EOF { + // The error didn't have a response body, + // so all we can do is return an error + // with the status code. + return core.NewAPIError(response.StatusCode, response.Header, nil) + } + return core.NewAPIError(response.StatusCode, response.Header, errors.New(string(bytes))) +} + +// isNil is used to determine if the request value is equal to nil (i.e. an interface +// value that holds a nil concrete value is itself non-nil). +func isNil(value interface{}) bool { + if value == nil { + return true + } + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} diff --git a/seed/go-sdk/go-global-headers/internal/caller_test.go b/seed/go-sdk/go-global-headers/internal/caller_test.go new file mode 100644 index 000000000000..1db4709f3d6f --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/caller_test.go @@ -0,0 +1,741 @@ +package internal + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/go-global-headers/fern/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// InternalTestCase represents a single test case. +type InternalTestCase struct { + description string + + // Server-side assertions. + givePathSuffix string + giveMethod string + giveResponseIsOptional bool + giveHeader http.Header + giveErrorDecoder ErrorDecoder + giveRequest *InternalTestRequest + giveQueryParams url.Values + giveBodyProperties map[string]interface{} + + // Client-side assertions. + wantResponse *InternalTestResponse + wantError error +} + +// InternalTestRequest a simple request body. +type InternalTestRequest struct { + Id string `json:"id"` +} + +// InternalTestResponse a simple response body. +type InternalTestResponse struct { + Id string `json:"id"` + ExtraBodyProperties map[string]interface{} `json:"extraBodyProperties,omitempty"` + QueryParameters url.Values `json:"queryParameters,omitempty"` +} + +// InternalTestNotFoundError represents a 404. +type InternalTestNotFoundError struct { + *core.APIError + + Message string `json:"message"` +} + +func TestCall(t *testing.T) { + tests := []*InternalTestCase{ + { + description: "GET success", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &InternalTestRequest{ + Id: "123", + }, + wantResponse: &InternalTestResponse{ + Id: "123", + }, + }, + { + description: "GET success with query", + givePathSuffix: "?limit=1", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &InternalTestRequest{ + Id: "123", + }, + wantResponse: &InternalTestResponse{ + Id: "123", + QueryParameters: url.Values{ + "limit": []string{"1"}, + }, + }, + }, + { + description: "GET not found", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: &InternalTestRequest{ + Id: strconv.Itoa(http.StatusNotFound), + }, + giveErrorDecoder: newTestErrorDecoder(t), + wantError: &InternalTestNotFoundError{ + APIError: core.NewAPIError( + http.StatusNotFound, + http.Header{}, + errors.New(`{"message":"ID \"404\" not found"}`), + ), + }, + }, + { + description: "POST empty body", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: nil, + wantError: core.NewAPIError( + http.StatusBadRequest, + http.Header{}, + errors.New("invalid request"), + ), + }, + { + description: "POST optional response", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &InternalTestRequest{ + Id: "123", + }, + giveResponseIsOptional: true, + }, + { + description: "POST API error", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: &InternalTestRequest{ + Id: strconv.Itoa(http.StatusInternalServerError), + }, + wantError: core.NewAPIError( + http.StatusInternalServerError, + http.Header{}, + errors.New("failed to process request"), + ), + }, + { + description: "POST extra properties", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: new(InternalTestRequest), + giveBodyProperties: map[string]interface{}{ + "key": "value", + }, + wantResponse: &InternalTestResponse{ + ExtraBodyProperties: map[string]interface{}{ + "key": "value", + }, + }, + }, + { + description: "GET extra query parameters", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveQueryParams: url.Values{ + "extra": []string{"true"}, + }, + giveRequest: &InternalTestRequest{ + Id: "123", + }, + wantResponse: &InternalTestResponse{ + Id: "123", + QueryParameters: url.Values{ + "extra": []string{"true"}, + }, + }, + }, + { + description: "GET merge extra query parameters", + givePathSuffix: "?limit=1", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &InternalTestRequest{ + Id: "123", + }, + giveQueryParams: url.Values{ + "extra": []string{"true"}, + }, + wantResponse: &InternalTestResponse{ + Id: "123", + QueryParameters: url.Values{ + "limit": []string{"1"}, + "extra": []string{"true"}, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + var ( + server = newTestServer(t, test) + client = server.Client() + ) + caller := NewCaller( + &CallerParams{ + Client: client, + }, + ) + var response *InternalTestResponse + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL + test.givePathSuffix, + Method: test.giveMethod, + Headers: test.giveHeader, + BodyProperties: test.giveBodyProperties, + QueryParameters: test.giveQueryParams, + Request: test.giveRequest, + Response: &response, + ResponseIsOptional: test.giveResponseIsOptional, + ErrorDecoder: test.giveErrorDecoder, + }, + ) + if test.wantError != nil { + assert.EqualError(t, err, test.wantError.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, test.wantResponse, response) + }) + } +} + +func TestCallWithGzipResponse(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "gzip", r.Header.Get("Accept-Encoding")) + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Type", "application/json") + gzipWriter := gzip.NewWriter(w) + _, err := gzipWriter.Write([]byte(`{"id": "123"}`)) + require.NoError(t, err) + require.NoError(t, gzipWriter.Close()) + }), + ) + defer server.Close() + + caller := NewCaller( + &CallerParams{ + Client: server.Client(), + }, + ) + var response *InternalTestResponse + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Headers: http.Header{ + "Accept-Encoding": []string{"gzip"}, + }, + Response: &response, + }, + ) + require.NoError(t, err) + assert.Equal(t, &InternalTestResponse{Id: "123"}, response) +} + +func TestMergeHeaders(t *testing.T) { + t.Run("both empty", func(t *testing.T) { + merged := MergeHeaders(make(http.Header), make(http.Header)) + assert.Empty(t, merged) + }) + + t.Run("empty left", func(t *testing.T) { + left := make(http.Header) + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, "0.0.1", merged.Get("X-API-Version")) + }) + + t.Run("empty right", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Version", "0.0.1") + + right := make(http.Header) + + merged := MergeHeaders(left, right) + assert.Equal(t, "0.0.1", merged.Get("X-API-Version")) + }) + + t.Run("single value override", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Version", "0.0.0") + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"0.0.1"}, merged.Values("X-API-Version")) + }) + + t.Run("multiple value override", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Versions", "0.0.0") + + right := make(http.Header) + right.Add("X-API-Versions", "0.0.1") + right.Add("X-API-Versions", "0.0.2") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"0.0.1", "0.0.2"}, merged.Values("X-API-Versions")) + }) + + t.Run("disjoint merge", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Tenancy", "test") + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"test"}, merged.Values("X-API-Tenancy")) + assert.Equal(t, []string{"0.0.1"}, merged.Values("X-API-Version")) + }) +} + +// newTestServer returns a new *httptest.Server configured with the +// given test parameters. +func newTestServer(t *testing.T, tc *InternalTestCase) *httptest.Server { + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.giveMethod, r.Method) + assert.Equal(t, contentType, r.Header.Get(contentTypeHeader)) + for header, value := range tc.giveHeader { + assert.Equal(t, value, r.Header.Values(header)) + } + + request := new(InternalTestRequest) + + bytes, err := io.ReadAll(r.Body) + if tc.giveRequest == nil { + require.Empty(t, bytes) + w.WriteHeader(http.StatusBadRequest) + _, err = w.Write([]byte("invalid request")) + require.NoError(t, err) + return + } + require.NoError(t, err) + require.NoError(t, json.Unmarshal(bytes, request)) + + switch request.Id { + case strconv.Itoa(http.StatusNotFound): + notFoundError := &InternalTestNotFoundError{ + APIError: &core.APIError{ + StatusCode: http.StatusNotFound, + }, + Message: fmt.Sprintf("ID %q not found", request.Id), + } + bytes, err = json.Marshal(notFoundError) + require.NoError(t, err) + + w.WriteHeader(http.StatusNotFound) + _, err = w.Write(bytes) + require.NoError(t, err) + return + + case strconv.Itoa(http.StatusInternalServerError): + w.WriteHeader(http.StatusInternalServerError) + _, err = w.Write([]byte("failed to process request")) + require.NoError(t, err) + return + } + + if tc.giveResponseIsOptional { + w.WriteHeader(http.StatusOK) + return + } + + extraBodyProperties := make(map[string]interface{}) + require.NoError(t, json.Unmarshal(bytes, &extraBodyProperties)) + delete(extraBodyProperties, "id") + + response := &InternalTestResponse{ + Id: request.Id, + ExtraBodyProperties: extraBodyProperties, + QueryParameters: r.URL.Query(), + } + bytes, err = json.Marshal(response) + require.NoError(t, err) + + _, err = w.Write(bytes) + require.NoError(t, err) + }, + ), + ) +} + +func TestIsNil(t *testing.T) { + t.Run("nil interface", func(t *testing.T) { + assert.True(t, isNil(nil)) + }) + + t.Run("nil pointer", func(t *testing.T) { + var ptr *string + assert.True(t, isNil(ptr)) + }) + + t.Run("non-nil pointer", func(t *testing.T) { + s := "test" + assert.False(t, isNil(&s)) + }) + + t.Run("nil slice", func(t *testing.T) { + var slice []string + assert.True(t, isNil(slice)) + }) + + t.Run("non-nil slice", func(t *testing.T) { + slice := []string{} + assert.False(t, isNil(slice)) + }) + + t.Run("nil map", func(t *testing.T) { + var m map[string]string + assert.True(t, isNil(m)) + }) + + t.Run("non-nil map", func(t *testing.T) { + m := make(map[string]string) + assert.False(t, isNil(m)) + }) + + t.Run("string value", func(t *testing.T) { + assert.False(t, isNil("test")) + }) + + t.Run("empty string value", func(t *testing.T) { + assert.False(t, isNil("")) + }) + + t.Run("int value", func(t *testing.T) { + assert.False(t, isNil(42)) + }) + + t.Run("zero int value", func(t *testing.T) { + assert.False(t, isNil(0)) + }) + + t.Run("bool value", func(t *testing.T) { + assert.False(t, isNil(true)) + }) + + t.Run("false bool value", func(t *testing.T) { + assert.False(t, isNil(false)) + }) + + t.Run("struct value", func(t *testing.T) { + type testStruct struct { + Field string + } + assert.False(t, isNil(testStruct{Field: "test"})) + }) + + t.Run("empty struct value", func(t *testing.T) { + type testStruct struct { + Field string + } + assert.False(t, isNil(testStruct{})) + }) +} + +// newTestErrorDecoder returns an error decoder suitable for tests. +func newTestErrorDecoder(t *testing.T) func(int, http.Header, io.Reader) error { + return func(statusCode int, header http.Header, body io.Reader) error { + raw, err := io.ReadAll(body) + require.NoError(t, err) + + var ( + apiError = core.NewAPIError(statusCode, header, errors.New(string(raw))) + decoder = json.NewDecoder(bytes.NewReader(raw)) + ) + if statusCode == http.StatusNotFound { + value := new(InternalTestNotFoundError) + value.APIError = apiError + require.NoError(t, decoder.Decode(value)) + + return value + } + return apiError + } +} + +// FormURLEncodedTestRequest is a test struct for form URL encoding tests. +type FormURLEncodedTestRequest struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + GrantType string `json:"grant_type,omitempty"` + Scope *string `json:"scope,omitempty"` + NilPointer *string `json:"nil_pointer,omitempty"` +} + +func TestNewFormURLEncodedBody(t *testing.T) { + t.Run("simple key-value pairs", func(t *testing.T) { + bodyProperties := map[string]interface{}{ + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "grant_type": "client_credentials", + } + reader := newFormURLEncodedBody(bodyProperties) + body, err := io.ReadAll(reader) + require.NoError(t, err) + + // Parse the body and verify values + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", values.Get("client_id")) + assert.Equal(t, "test_client_secret", values.Get("client_secret")) + assert.Equal(t, "client_credentials", values.Get("grant_type")) + + // Verify it's not JSON + bodyStr := string(body) + assert.False(t, strings.HasPrefix(strings.TrimSpace(bodyStr), "{"), + "Body should not be JSON, got: %s", bodyStr) + }) + + t.Run("special characters requiring URL encoding", func(t *testing.T) { + bodyProperties := map[string]interface{}{ + "value_with_space": "hello world", + "value_with_ampersand": "a&b", + "value_with_equals": "a=b", + "value_with_plus": "a+b", + } + reader := newFormURLEncodedBody(bodyProperties) + body, err := io.ReadAll(reader) + require.NoError(t, err) + + // Parse the body and verify values are correctly decoded + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "hello world", values.Get("value_with_space")) + assert.Equal(t, "a&b", values.Get("value_with_ampersand")) + assert.Equal(t, "a=b", values.Get("value_with_equals")) + assert.Equal(t, "a+b", values.Get("value_with_plus")) + }) + + t.Run("empty map", func(t *testing.T) { + bodyProperties := map[string]interface{}{} + reader := newFormURLEncodedBody(bodyProperties) + body, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Empty(t, string(body)) + }) +} + +func TestNewFormURLEncodedRequestBody(t *testing.T) { + t.Run("struct with json tags", func(t *testing.T) { + scope := "read write" + request := &FormURLEncodedTestRequest{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + GrantType: "client_credentials", + Scope: &scope, + NilPointer: nil, + } + reader, err := newFormURLEncodedRequestBody(request, nil) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + // Parse the body and verify values + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", values.Get("client_id")) + assert.Equal(t, "test_client_secret", values.Get("client_secret")) + assert.Equal(t, "client_credentials", values.Get("grant_type")) + assert.Equal(t, "read write", values.Get("scope")) + // nil_pointer should not be present (nil pointer with omitempty) + assert.Empty(t, values.Get("nil_pointer")) + + // Verify it's not JSON + bodyStr := string(body) + assert.False(t, strings.HasPrefix(strings.TrimSpace(bodyStr), "{"), + "Body should not be JSON, got: %s", bodyStr) + }) + + t.Run("struct with omitempty and zero values", func(t *testing.T) { + request := &FormURLEncodedTestRequest{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + GrantType: "", // empty string with omitempty should be omitted + Scope: nil, + NilPointer: nil, + } + reader, err := newFormURLEncodedRequestBody(request, nil) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", values.Get("client_id")) + assert.Equal(t, "test_client_secret", values.Get("client_secret")) + // grant_type should not be present (empty string with omitempty) + assert.Empty(t, values.Get("grant_type")) + assert.Empty(t, values.Get("scope")) + }) + + t.Run("struct with extra body properties", func(t *testing.T) { + request := &FormURLEncodedTestRequest{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + } + bodyProperties := map[string]interface{}{ + "extra_param": "extra_value", + } + reader, err := newFormURLEncodedRequestBody(request, bodyProperties) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", values.Get("client_id")) + assert.Equal(t, "test_client_secret", values.Get("client_secret")) + assert.Equal(t, "extra_value", values.Get("extra_param")) + }) + + t.Run("special characters in struct fields", func(t *testing.T) { + scope := "read&write=all+permissions" + request := &FormURLEncodedTestRequest{ + ClientID: "client with spaces", + ClientSecret: "secret&with=special+chars", + Scope: &scope, + } + reader, err := newFormURLEncodedRequestBody(request, nil) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "client with spaces", values.Get("client_id")) + assert.Equal(t, "secret&with=special+chars", values.Get("client_secret")) + assert.Equal(t, "read&write=all+permissions", values.Get("scope")) + }) +} + +func TestNewRequestBodyFormURLEncoded(t *testing.T) { + t.Run("selects form encoding when content-type is form-urlencoded", func(t *testing.T) { + request := &FormURLEncodedTestRequest{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + GrantType: "client_credentials", + } + reader, err := newRequestBody(request, nil, contentTypeFormURLEncoded) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + // Verify it's form-urlencoded, not JSON + bodyStr := string(body) + assert.False(t, strings.HasPrefix(strings.TrimSpace(bodyStr), "{"), + "Body should not be JSON when Content-Type is form-urlencoded, got: %s", bodyStr) + + // Parse and verify values + values, err := url.ParseQuery(bodyStr) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", values.Get("client_id")) + assert.Equal(t, "test_client_secret", values.Get("client_secret")) + assert.Equal(t, "client_credentials", values.Get("grant_type")) + }) + + t.Run("selects JSON encoding when content-type is application/json", func(t *testing.T) { + request := &FormURLEncodedTestRequest{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + } + reader, err := newRequestBody(request, nil, contentType) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + // Verify it's JSON + bodyStr := string(body) + assert.True(t, strings.HasPrefix(strings.TrimSpace(bodyStr), "{"), + "Body should be JSON when Content-Type is application/json, got: %s", bodyStr) + + // Parse and verify it's valid JSON + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", parsed["client_id"]) + assert.Equal(t, "test_client_secret", parsed["client_secret"]) + }) + + t.Run("form encoding with body properties only (nil request)", func(t *testing.T) { + bodyProperties := map[string]interface{}{ + "client_id": "test_client_id", + "client_secret": "test_client_secret", + } + reader, err := newRequestBody(nil, bodyProperties, contentTypeFormURLEncoded) + require.NoError(t, err) + + body, err := io.ReadAll(reader) + require.NoError(t, err) + + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "test_client_id", values.Get("client_id")) + assert.Equal(t, "test_client_secret", values.Get("client_secret")) + }) +} diff --git a/seed/go-sdk/go-global-headers/internal/error_decoder.go b/seed/go-sdk/go-global-headers/internal/error_decoder.go new file mode 100644 index 000000000000..aeb1f2b53d74 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/error_decoder.go @@ -0,0 +1,64 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/go-global-headers/fern/core" +) + +// ErrorCodes maps HTTP status codes to error constructors. +type ErrorCodes map[int]func(*core.APIError) error + +// ErrorDecoder decodes *http.Response errors and returns a +// typed API error (e.g. *core.APIError). +type ErrorDecoder func(statusCode int, header http.Header, body io.Reader) error + +// NewErrorDecoder returns a new ErrorDecoder backed by the given error codes. +// errorCodesOverrides is optional and will be merged with the default error codes, +// with overrides taking precedence. +func NewErrorDecoder(errorCodes ErrorCodes, errorCodesOverrides ...ErrorCodes) ErrorDecoder { + // Merge default error codes with overrides + mergedErrorCodes := make(ErrorCodes) + + // Start with default error codes + for statusCode, errorFunc := range errorCodes { + mergedErrorCodes[statusCode] = errorFunc + } + + // Apply overrides if provided + if len(errorCodesOverrides) > 0 && errorCodesOverrides[0] != nil { + for statusCode, errorFunc := range errorCodesOverrides[0] { + mergedErrorCodes[statusCode] = errorFunc + } + } + + return func(statusCode int, header http.Header, body io.Reader) error { + raw, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed to read error from response body: %w", err) + } + apiError := core.NewAPIError( + statusCode, + header, + errors.New(string(raw)), + ) + newErrorFunc, ok := mergedErrorCodes[statusCode] + if !ok { + // This status code isn't recognized, so we return + // the API error as-is. + return apiError + } + customError := newErrorFunc(apiError) + if err := json.NewDecoder(bytes.NewReader(raw)).Decode(customError); err != nil { + // If we fail to decode the error, we return the + // API error as-is. + return apiError + } + return customError + } +} diff --git a/seed/go-sdk/go-global-headers/internal/error_decoder_test.go b/seed/go-sdk/go-global-headers/internal/error_decoder_test.go new file mode 100644 index 000000000000..1a4cce19fa85 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/error_decoder_test.go @@ -0,0 +1,59 @@ +package internal + +import ( + "bytes" + "errors" + "net/http" + "testing" + + "github.com/go-global-headers/fern/core" + "github.com/stretchr/testify/assert" +) + +func TestErrorDecoder(t *testing.T) { + decoder := NewErrorDecoder( + ErrorCodes{ + http.StatusNotFound: func(apiError *core.APIError) error { + return &InternalTestNotFoundError{APIError: apiError} + }, + }) + + tests := []struct { + description string + giveStatusCode int + giveHeader http.Header + giveBody string + wantError error + }{ + { + description: "unrecognized status code", + giveStatusCode: http.StatusInternalServerError, + giveHeader: http.Header{}, + giveBody: "Internal Server Error", + wantError: core.NewAPIError(http.StatusInternalServerError, http.Header{}, errors.New("Internal Server Error")), + }, + { + description: "not found with valid JSON", + giveStatusCode: http.StatusNotFound, + giveHeader: http.Header{}, + giveBody: `{"message": "Resource not found"}`, + wantError: &InternalTestNotFoundError{ + APIError: core.NewAPIError(http.StatusNotFound, http.Header{}, errors.New(`{"message": "Resource not found"}`)), + Message: "Resource not found", + }, + }, + { + description: "not found with invalid JSON", + giveStatusCode: http.StatusNotFound, + giveHeader: http.Header{}, + giveBody: `Resource not found`, + wantError: core.NewAPIError(http.StatusNotFound, http.Header{}, errors.New("Resource not found")), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + assert.Equal(t, tt.wantError, decoder(tt.giveStatusCode, tt.giveHeader, bytes.NewReader([]byte(tt.giveBody)))) + }) + } +} diff --git a/seed/go-sdk/go-global-headers/internal/explicit_fields.go b/seed/go-sdk/go-global-headers/internal/explicit_fields.go new file mode 100644 index 000000000000..4bdf34fc2b7c --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/explicit_fields.go @@ -0,0 +1,116 @@ +package internal + +import ( + "math/big" + "reflect" + "strings" +) + +// HandleExplicitFields processes a struct to remove `omitempty` from +// fields that have been explicitly set (as indicated by their corresponding bit in explicitFields). +// Note that `marshaler` should be an embedded struct to avoid infinite recursion. +// Returns an interface{} that can be passed to json.Marshal. +func HandleExplicitFields(marshaler interface{}, explicitFields *big.Int) interface{} { + val := reflect.ValueOf(marshaler) + typ := reflect.TypeOf(marshaler) + + // Handle pointer types + if val.Kind() == reflect.Ptr { + if val.IsNil() { + return nil + } + val = val.Elem() + typ = typ.Elem() + } + + // Only handle struct types + if val.Kind() != reflect.Struct { + return marshaler + } + + // Handle embedded struct pattern + var sourceVal reflect.Value + var sourceType reflect.Type + + // Check if this is an embedded struct pattern + if typ.NumField() == 1 && typ.Field(0).Anonymous { + // This is likely an embedded struct, get the embedded value + embeddedField := val.Field(0) + sourceVal = embeddedField + sourceType = embeddedField.Type() + } else { + // Regular struct + sourceVal = val + sourceType = typ + } + + // If no explicit fields set, use standard marshaling + if explicitFields == nil || explicitFields.Sign() == 0 { + return marshaler + } + + // Create a new struct type with modified tags + fields := make([]reflect.StructField, 0, sourceType.NumField()) + + for i := 0; i < sourceType.NumField(); i++ { + field := sourceType.Field(i) + + // Skip unexported fields and the explicitFields field itself + if !field.IsExported() || field.Name == "explicitFields" { + continue + } + + // Check if this field has been explicitly set + fieldBit := big.NewInt(1) + fieldBit.Lsh(fieldBit, uint(i)) + if big.NewInt(0).And(explicitFields, fieldBit).Sign() != 0 { + // Remove omitempty from the json tag + tag := field.Tag.Get("json") + if tag != "" && tag != "-" { + // Parse the json tag, remove omitempty from options + parts := strings.Split(tag, ",") + if len(parts) > 1 { + var newParts []string + newParts = append(newParts, parts[0]) // Keep the field name + for _, part := range parts[1:] { + if strings.TrimSpace(part) != "omitempty" { + newParts = append(newParts, part) + } + } + tag = strings.Join(newParts, ",") + } + + // Reconstruct the struct tag + newTag := `json:"` + tag + `"` + if urlTag := field.Tag.Get("url"); urlTag != "" { + newTag += ` url:"` + urlTag + `"` + } + + field.Tag = reflect.StructTag(newTag) + } + } + + fields = append(fields, field) + } + + // Create new struct type with modified tags + newType := reflect.StructOf(fields) + newVal := reflect.New(newType).Elem() + + // Copy field values from original struct to new struct + fieldIndex := 0 + for i := 0; i < sourceType.NumField(); i++ { + originalField := sourceType.Field(i) + + // Skip unexported fields and the explicitFields field itself + if !originalField.IsExported() || originalField.Name == "explicitFields" { + continue + } + + originalValue := sourceVal.Field(i) + newVal.Field(fieldIndex).Set(originalValue) + fieldIndex++ + } + + return newVal.Interface() +} diff --git a/seed/go-sdk/go-global-headers/internal/explicit_fields_test.go b/seed/go-sdk/go-global-headers/internal/explicit_fields_test.go new file mode 100644 index 000000000000..f44beec447d6 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/explicit_fields_test.go @@ -0,0 +1,645 @@ +package internal + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testExplicitFieldsStruct struct { + Name *string `json:"name,omitempty"` + Code *string `json:"code,omitempty"` + Count *int `json:"count,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Tags []string `json:"tags,omitempty"` + unexported string `json:"-"` //nolint:unused + explicitFields *big.Int `json:"-"` +} + +var ( + testFieldName = big.NewInt(1 << 0) + testFieldCode = big.NewInt(1 << 1) + testFieldCount = big.NewInt(1 << 2) + testFieldEnabled = big.NewInt(1 << 3) + testFieldTags = big.NewInt(1 << 4) +) + +func (t *testExplicitFieldsStruct) require(field *big.Int) { + if t.explicitFields == nil { + t.explicitFields = big.NewInt(0) + } + t.explicitFields.Or(t.explicitFields, field) +} + +func (t *testExplicitFieldsStruct) SetName(name *string) { + t.Name = name + t.require(testFieldName) +} + +func (t *testExplicitFieldsStruct) SetCode(code *string) { + t.Code = code + t.require(testFieldCode) +} + +func (t *testExplicitFieldsStruct) SetCount(count *int) { + t.Count = count + t.require(testFieldCount) +} + +func (t *testExplicitFieldsStruct) SetEnabled(enabled *bool) { + t.Enabled = enabled + t.require(testFieldEnabled) +} + +func (t *testExplicitFieldsStruct) SetTags(tags []string) { + t.Tags = tags + t.require(testFieldTags) +} + +func (t *testExplicitFieldsStruct) MarshalJSON() ([]byte, error) { + type embed testExplicitFieldsStruct + var marshaler = struct { + embed + }{ + embed: embed(*t), + } + return json.Marshal(HandleExplicitFields(marshaler, t.explicitFields)) +} + +type testStructWithoutExplicitFields struct { + Name *string `json:"name,omitempty"` + Code *string `json:"code,omitempty"` +} + +func TestHandleExplicitFields(t *testing.T) { + tests := []struct { + desc string + giveInput interface{} + wantBytes []byte + wantError string + }{ + { + desc: "nil input", + giveInput: nil, + wantBytes: []byte(`null`), + }, + { + desc: "non-struct input", + giveInput: "string", + wantBytes: []byte(`"string"`), + }, + { + desc: "slice input", + giveInput: []string{"a", "b"}, + wantBytes: []byte(`["a","b"]`), + }, + { + desc: "map input", + giveInput: map[string]interface{}{"key": "value"}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "struct without explicitFields field", + giveInput: &testStructWithoutExplicitFields{ + Name: stringPtr("test"), + Code: nil, + }, + wantBytes: []byte(`{"name":"test"}`), + }, + { + desc: "struct with no explicit fields set", + giveInput: &testExplicitFieldsStruct{ + Name: stringPtr("test"), + Code: nil, + }, + wantBytes: []byte(`{"name":"test"}`), + }, + { + desc: "struct with explicit nil field", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{ + Name: stringPtr("test"), + } + s.SetCode(nil) + return s + }(), + wantBytes: []byte(`{"name":"test","code":null}`), + }, + { + desc: "struct with explicit non-nil field", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{} + s.SetName(stringPtr("explicit")) + s.SetCode(stringPtr("also-explicit")) + return s + }(), + wantBytes: []byte(`{"name":"explicit","code":"also-explicit"}`), + }, + { + desc: "struct with mixed explicit and implicit fields", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{ + Name: stringPtr("implicit"), + Count: intPtr(42), + } + s.SetCode(nil) // explicit nil + return s + }(), + wantBytes: []byte(`{"name":"implicit","code":null,"count":42}`), + }, + { + desc: "struct with multiple explicit nil fields", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{ + Name: stringPtr("test"), + } + s.SetCode(nil) + s.SetCount(nil) + return s + }(), + wantBytes: []byte(`{"name":"test","code":null,"count":null}`), + }, + { + desc: "struct with slice field", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{ + Tags: []string{"tag1", "tag2"}, + } + s.SetTags(nil) // explicit nil slice + return s + }(), + wantBytes: []byte(`{"tags":null}`), + }, + { + desc: "struct with boolean field", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{} + s.SetEnabled(boolPtr(false)) // explicit false + return s + }(), + wantBytes: []byte(`{"enabled":false}`), + }, + { + desc: "struct with all fields explicit", + giveInput: func() *testExplicitFieldsStruct { + s := &testExplicitFieldsStruct{} + s.SetName(stringPtr("test")) + s.SetCode(nil) + s.SetCount(intPtr(0)) + s.SetEnabled(boolPtr(false)) + s.SetTags([]string{}) + return s + }(), + wantBytes: []byte(`{"name":"test","code":null,"count":0,"enabled":false,"tags":[]}`), + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + var explicitFields *big.Int + if s, ok := tt.giveInput.(*testExplicitFieldsStruct); ok { + explicitFields = s.explicitFields + } + bytes, err := json.Marshal(HandleExplicitFields(tt.giveInput, explicitFields)) + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + assert.Nil(t, tt.wantBytes) + return + } + require.NoError(t, err) + assert.JSONEq(t, string(tt.wantBytes), string(bytes)) + + // Verify it's valid JSON + var value interface{} + require.NoError(t, json.Unmarshal(bytes, &value)) + }) + } +} + +func TestHandleExplicitFieldsCustomMarshaler(t *testing.T) { + t.Run("custom marshaler with explicit fields", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + s.SetName(nil) + s.SetCode(stringPtr("test-code")) + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, `{"name":null,"code":"test-code"}`, string(bytes)) + }) + + t.Run("custom marshaler with no explicit fields", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Name: stringPtr("implicit"), + Code: stringPtr("also-implicit"), + } + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, `{"name":"implicit","code":"also-implicit"}`, string(bytes)) + }) +} + +func TestHandleExplicitFieldsPointerHandling(t *testing.T) { + t.Run("nil pointer", func(t *testing.T) { + var s *testExplicitFieldsStruct + bytes, err := json.Marshal(HandleExplicitFields(s, nil)) + require.NoError(t, err) + assert.Equal(t, []byte(`null`), bytes) + }) + + t.Run("pointer to struct", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + s.SetName(nil) + + bytes, err := json.Marshal(HandleExplicitFields(s, s.explicitFields)) + require.NoError(t, err) + assert.JSONEq(t, `{"name":null}`, string(bytes)) + }) +} + +func TestHandleExplicitFieldsEmbeddedStruct(t *testing.T) { + t.Run("embedded struct with explicit fields", func(t *testing.T) { + // Create a struct similar to what MarshalJSON creates + s := &testExplicitFieldsStruct{} + s.SetName(nil) + s.SetCode(stringPtr("test-code")) + + type embed testExplicitFieldsStruct + var marshaler = struct { + embed + }{ + embed: embed(*s), + } + + bytes, err := json.Marshal(HandleExplicitFields(marshaler, s.explicitFields)) + require.NoError(t, err) + // Should include both explicit fields (name as null, code as "test-code") + assert.JSONEq(t, `{"name":null,"code":"test-code"}`, string(bytes)) + }) + + t.Run("embedded struct with no explicit fields", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Name: stringPtr("implicit"), + Code: stringPtr("also-implicit"), + } + + type embed testExplicitFieldsStruct + var marshaler = struct { + embed + }{ + embed: embed(*s), + } + + bytes, err := json.Marshal(HandleExplicitFields(marshaler, s.explicitFields)) + require.NoError(t, err) + // Should only include non-nil fields (omitempty behavior) + assert.JSONEq(t, `{"name":"implicit","code":"also-implicit"}`, string(bytes)) + }) + + t.Run("embedded struct with mixed fields", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Count: intPtr(42), // implicit field + } + s.SetName(nil) // explicit nil + s.SetCode(stringPtr("explicit")) // explicit value + + type embed testExplicitFieldsStruct + var marshaler = struct { + embed + }{ + embed: embed(*s), + } + + bytes, err := json.Marshal(HandleExplicitFields(marshaler, s.explicitFields)) + require.NoError(t, err) + // Should include explicit null, explicit value, and implicit value + assert.JSONEq(t, `{"name":null,"code":"explicit","count":42}`, string(bytes)) + }) +} + +func TestHandleExplicitFieldsTagHandling(t *testing.T) { + type testStructWithComplexTags struct { + Field1 *string `json:"field1,omitempty" url:"field1,omitempty"` + Field2 *string `json:"field2,omitempty,string" url:"field2"` + Field3 *string `json:"-"` + Field4 *string `json:"field4"` + explicitFields *big.Int `json:"-"` + } + + s := &testStructWithComplexTags{ + Field1: stringPtr("test1"), + Field4: stringPtr("test4"), + explicitFields: big.NewInt(1), // Only first field is explicit + } + + bytes, err := json.Marshal(HandleExplicitFields(s, s.explicitFields)) + require.NoError(t, err) + + // Field1 should have omitempty removed, Field2 should keep omitempty, Field4 should be included + assert.JSONEq(t, `{"field1":"test1","field4":"test4"}`, string(bytes)) +} + +// Test types for nested struct explicit fields testing +type testNestedStruct struct { + NestedName *string `json:"nested_name,omitempty"` + NestedCode *string `json:"nested_code,omitempty"` + explicitFields *big.Int `json:"-"` +} + +type testParentStruct struct { + ParentName *string `json:"parent_name,omitempty"` + Nested *testNestedStruct `json:"nested,omitempty"` + explicitFields *big.Int `json:"-"` +} + +var ( + nestedFieldName = big.NewInt(1 << 0) + nestedFieldCode = big.NewInt(1 << 1) +) + +var ( + parentFieldName = big.NewInt(1 << 0) + parentFieldNested = big.NewInt(1 << 1) +) + +func (n *testNestedStruct) require(field *big.Int) { + if n.explicitFields == nil { + n.explicitFields = big.NewInt(0) + } + n.explicitFields.Or(n.explicitFields, field) +} + +func (n *testNestedStruct) SetNestedName(name *string) { + n.NestedName = name + n.require(nestedFieldName) +} + +func (n *testNestedStruct) SetNestedCode(code *string) { + n.NestedCode = code + n.require(nestedFieldCode) +} + +func (n *testNestedStruct) MarshalJSON() ([]byte, error) { + type embed testNestedStruct + var marshaler = struct { + embed + }{ + embed: embed(*n), + } + return json.Marshal(HandleExplicitFields(marshaler, n.explicitFields)) +} + +func (p *testParentStruct) require(field *big.Int) { + if p.explicitFields == nil { + p.explicitFields = big.NewInt(0) + } + p.explicitFields.Or(p.explicitFields, field) +} + +func (p *testParentStruct) SetParentName(name *string) { + p.ParentName = name + p.require(parentFieldName) +} + +func (p *testParentStruct) SetNested(nested *testNestedStruct) { + p.Nested = nested + p.require(parentFieldNested) +} + +func (p *testParentStruct) MarshalJSON() ([]byte, error) { + type embed testParentStruct + var marshaler = struct { + embed + }{ + embed: embed(*p), + } + return json.Marshal(HandleExplicitFields(marshaler, p.explicitFields)) +} + +func TestHandleExplicitFieldsNestedStruct(t *testing.T) { + tests := []struct { + desc string + setupFunc func() *testParentStruct + wantBytes []byte + }{ + { + desc: "nested struct with explicit nil in nested object", + setupFunc: func() *testParentStruct { + nested := &testNestedStruct{ + NestedName: stringPtr("implicit-nested"), + } + nested.SetNestedCode(nil) // explicit nil + + return &testParentStruct{ + ParentName: stringPtr("implicit-parent"), + Nested: nested, + } + }, + wantBytes: []byte(`{"parent_name":"implicit-parent","nested":{"nested_name":"implicit-nested","nested_code":null}}`), + }, + { + desc: "parent with explicit nil nested struct", + setupFunc: func() *testParentStruct { + parent := &testParentStruct{ + ParentName: stringPtr("implicit-parent"), + } + parent.SetNested(nil) // explicit nil nested struct + return parent + }, + wantBytes: []byte(`{"parent_name":"implicit-parent","nested":null}`), + }, + { + desc: "all explicit fields in nested structure", + setupFunc: func() *testParentStruct { + nested := &testNestedStruct{} + nested.SetNestedName(stringPtr("explicit-nested")) + nested.SetNestedCode(nil) // explicit nil + + parent := &testParentStruct{} + parent.SetParentName(nil) // explicit nil + parent.SetNested(nested) // explicit nested struct + + return parent + }, + wantBytes: []byte(`{"parent_name":null,"nested":{"nested_name":"explicit-nested","nested_code":null}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + parent := tt.setupFunc() + bytes, err := parent.MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, string(tt.wantBytes), string(bytes)) + + // Verify it's valid JSON + var value interface{} + require.NoError(t, json.Unmarshal(bytes, &value)) + }) + } +} + +// Test for setter method documentation and behavior +func TestSetterMethodsDocumentation(t *testing.T) { + t.Run("setter prevents omitempty for nil values", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + + // Use setter to explicitly set nil - this should prevent omitempty + s.SetName(nil) + s.SetCode(nil) + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Both fields should be included as null, not omitted + assert.JSONEq(t, `{"name":null,"code":null}`, string(bytes)) + }) + + t.Run("setter prevents omitempty for empty slice", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + + // Use setter to explicitly set empty slice + s.SetTags([]string{}) + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Empty slice should be included as [], not omitted + assert.JSONEq(t, `{"tags":[]}`, string(bytes)) + }) + + t.Run("setter prevents omitempty for zero values", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + + // Use setter to explicitly set zero values + s.SetCount(intPtr(0)) + s.SetEnabled(boolPtr(false)) + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Zero values should be included, not omitted + assert.JSONEq(t, `{"count":0,"enabled":false}`, string(bytes)) + }) + + t.Run("direct assignment is omitted when nil", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Name: nil, // Direct assignment, not using setter + Code: nil, // Direct assignment, not using setter + } + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Fields not set via setter should be omitted when nil + assert.JSONEq(t, `{}`, string(bytes)) + }) + + t.Run("mix of setter and direct assignment", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Name: stringPtr("direct"), // Direct assignment + Count: intPtr(42), // Direct assignment + } + s.SetCode(nil) // Setter with nil + s.SetEnabled(boolPtr(false)) // Setter with zero value + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Direct assignments included if non-nil, setter fields always included + assert.JSONEq(t, `{"name":"direct","code":null,"count":42,"enabled":false}`, string(bytes)) + }) +} + +// Test for complex scenarios with multiple setters +func TestComplexSetterScenarios(t *testing.T) { + t.Run("multiple setter calls on same field", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + + // Call setter multiple times - last one should win + s.SetName(stringPtr("first")) + s.SetName(stringPtr("second")) + s.SetName(nil) // Final value is nil + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Should serialize the last set value (nil) + assert.JSONEq(t, `{"name":null}`, string(bytes)) + }) + + t.Run("setter after direct assignment", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Name: stringPtr("direct"), + } + + // Override with setter + s.SetName(nil) + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // Setter should mark field as explicit, so nil is serialized + assert.JSONEq(t, `{"name":null}`, string(bytes)) + }) + + t.Run("all fields set via setters", func(t *testing.T) { + s := &testExplicitFieldsStruct{} + s.SetName(nil) + s.SetCode(stringPtr("")) // Empty string + s.SetCount(intPtr(0)) // Zero + s.SetEnabled(boolPtr(false)) // False + s.SetTags(nil) // Nil slice + + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + // All fields should be present even with nil/zero values + assert.JSONEq(t, `{"name":null,"code":"","count":0,"enabled":false,"tags":null}`, string(bytes)) + }) +} + +// Test for backwards compatibility +func TestBackwardsCompatibility(t *testing.T) { + t.Run("struct without setters behaves normally", func(t *testing.T) { + s := &testStructWithoutExplicitFields{ + Name: stringPtr("test"), + Code: nil, // This should be omitted + } + + bytes, err := json.Marshal(s) + require.NoError(t, err) + + // Without setters, omitempty works normally + assert.JSONEq(t, `{"name":"test"}`, string(bytes)) + }) + + t.Run("struct with explicit fields works with standard json.Marshal", func(t *testing.T) { + s := &testExplicitFieldsStruct{ + Name: stringPtr("test"), + } + s.SetCode(nil) + + // Using the custom MarshalJSON + bytes, err := s.MarshalJSON() + require.NoError(t, err) + + assert.JSONEq(t, `{"name":"test","code":null}`, string(bytes)) + }) +} + +// Helper functions +func stringPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/seed/go-sdk/go-global-headers/internal/extra_properties.go b/seed/go-sdk/go-global-headers/internal/extra_properties.go new file mode 100644 index 000000000000..540c3fd89eeb --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/extra_properties.go @@ -0,0 +1,141 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// MarshalJSONWithExtraProperty marshals the given value to JSON, including the extra property. +func MarshalJSONWithExtraProperty(marshaler interface{}, key string, value interface{}) ([]byte, error) { + return MarshalJSONWithExtraProperties(marshaler, map[string]interface{}{key: value}) +} + +// MarshalJSONWithExtraProperties marshals the given value to JSON, including any extra properties. +func MarshalJSONWithExtraProperties(marshaler interface{}, extraProperties map[string]interface{}) ([]byte, error) { + bytes, err := json.Marshal(marshaler) + if err != nil { + return nil, err + } + if len(extraProperties) == 0 { + return bytes, nil + } + keys, err := getKeys(marshaler) + if err != nil { + return nil, err + } + for _, key := range keys { + if _, ok := extraProperties[key]; ok { + return nil, fmt.Errorf("cannot add extra property %q because it is already defined on the type", key) + } + } + extraBytes, err := json.Marshal(extraProperties) + if err != nil { + return nil, err + } + if isEmptyJSON(bytes) { + if isEmptyJSON(extraBytes) { + return bytes, nil + } + return extraBytes, nil + } + result := bytes[:len(bytes)-1] + result = append(result, ',') + result = append(result, extraBytes[1:len(extraBytes)-1]...) + result = append(result, '}') + return result, nil +} + +// ExtractExtraProperties extracts any extra properties from the given value. +func ExtractExtraProperties(bytes []byte, value interface{}, exclude ...string) (map[string]interface{}, error) { + val := reflect.ValueOf(value) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return nil, fmt.Errorf("value must be non-nil to extract extra properties") + } + val = val.Elem() + } + if err := json.Unmarshal(bytes, &value); err != nil { + return nil, err + } + var extraProperties map[string]interface{} + if err := json.Unmarshal(bytes, &extraProperties); err != nil { + return nil, err + } + for i := 0; i < val.Type().NumField(); i++ { + key := jsonKey(val.Type().Field(i)) + if key == "" || key == "-" { + continue + } + delete(extraProperties, key) + } + for _, key := range exclude { + delete(extraProperties, key) + } + if len(extraProperties) == 0 { + return nil, nil + } + return extraProperties, nil +} + +// getKeys returns the keys associated with the given value. The value must be a +// a struct or a map with string keys. +func getKeys(value interface{}) ([]string, error) { + val := reflect.ValueOf(value) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if !val.IsValid() { + return nil, nil + } + switch val.Kind() { + case reflect.Struct: + return getKeysForStructType(val.Type()), nil + case reflect.Map: + var keys []string + if val.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } + for _, key := range val.MapKeys() { + keys = append(keys, key.String()) + } + return keys, nil + default: + return nil, fmt.Errorf("cannot extract keys from %T; only structs and maps with string keys are supported", value) + } +} + +// getKeysForStructType returns all the keys associated with the given struct type, +// visiting embedded fields recursively. +func getKeysForStructType(structType reflect.Type) []string { + if structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct { + return nil + } + var keys []string + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + if field.Anonymous { + keys = append(keys, getKeysForStructType(field.Type)...) + continue + } + keys = append(keys, jsonKey(field)) + } + return keys +} + +// jsonKey returns the JSON key from the struct tag of the given field, +// excluding the omitempty flag (if any). +func jsonKey(field reflect.StructField) string { + return strings.TrimSuffix(field.Tag.Get("json"), ",omitempty") +} + +// isEmptyJSON returns true if the given data is empty, the empty JSON object, or +// an explicit null. +func isEmptyJSON(data []byte) bool { + return len(data) <= 2 || bytes.Equal(data, []byte("null")) +} diff --git a/seed/go-sdk/go-global-headers/internal/extra_properties_test.go b/seed/go-sdk/go-global-headers/internal/extra_properties_test.go new file mode 100644 index 000000000000..aa2510ee5121 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/extra_properties_test.go @@ -0,0 +1,228 @@ +package internal + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testMarshaler struct { + Name string `json:"name"` + BirthDate time.Time `json:"birthDate"` + CreatedAt time.Time `json:"created_at"` +} + +func (t *testMarshaler) MarshalJSON() ([]byte, error) { + type embed testMarshaler + var marshaler = struct { + embed + BirthDate string `json:"birthDate"` + CreatedAt string `json:"created_at"` + }{ + embed: embed(*t), + BirthDate: t.BirthDate.Format("2006-01-02"), + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + return MarshalJSONWithExtraProperty(marshaler, "type", "test") +} + +func TestMarshalJSONWithExtraProperties(t *testing.T) { + tests := []struct { + desc string + giveMarshaler interface{} + giveExtraProperties map[string]interface{} + wantBytes []byte + wantError string + }{ + { + desc: "invalid type", + giveMarshaler: []string{"invalid"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from []string; only structs and maps with string keys are supported`, + }, + { + desc: "invalid key type", + giveMarshaler: map[int]interface{}{42: "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot extract keys from map[int]interface {}; only structs and maps with string keys are supported`, + }, + { + desc: "invalid map overwrite", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"key": "overwrite"}, + wantError: `cannot add extra property "key" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"birthDate": "2000-01-01"}, + wantError: `cannot add extra property "birthDate" because it is already defined on the type`, + }, + { + desc: "invalid struct overwrite embedded type", + giveMarshaler: new(testMarshaler), + giveExtraProperties: map[string]interface{}{"name": "bob"}, + wantError: `cannot add extra property "name" because it is already defined on the type`, + }, + { + desc: "nil", + giveMarshaler: nil, + giveExtraProperties: nil, + wantBytes: []byte(`null`), + }, + { + desc: "empty", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{}`), + }, + { + desc: "no extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "only extra properties", + giveMarshaler: map[string]interface{}{}, + giveExtraProperties: map[string]interface{}{"key": "value"}, + wantBytes: []byte(`{"key":"value"}`), + }, + { + desc: "single extra property", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"extra": "property"}, + wantBytes: []byte(`{"key":"value","extra":"property"}`), + }, + { + desc: "multiple extra properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{"one": 1, "two": 2}, + wantBytes: []byte(`{"key":"value","one":1,"two":2}`), + }, + { + desc: "nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","user":{"age":42,"name":"alice"}}`), + }, + { + desc: "multiple nested properties", + giveMarshaler: map[string]interface{}{"key": "value"}, + giveExtraProperties: map[string]interface{}{ + "metadata": map[string]interface{}{ + "ip": "127.0.0.1", + }, + "user": map[string]interface{}{ + "age": 42, + "name": "alice", + }, + }, + wantBytes: []byte(`{"key":"value","metadata":{"ip":"127.0.0.1"},"user":{"age":42,"name":"alice"}}`), + }, + { + desc: "custom marshaler", + giveMarshaler: &testMarshaler{ + Name: "alice", + BirthDate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }, + giveExtraProperties: map[string]interface{}{ + "extra": "property", + }, + wantBytes: []byte(`{"name":"alice","birthDate":"2000-01-01","created_at":"2024-01-01T00:00:00Z","type":"test","extra":"property"}`), + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + bytes, err := MarshalJSONWithExtraProperties(tt.giveMarshaler, tt.giveExtraProperties) + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + assert.Nil(t, tt.wantBytes) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantBytes, bytes) + + value := make(map[string]interface{}) + require.NoError(t, json.Unmarshal(bytes, &value)) + }) + } +} + +func TestExtractExtraProperties(t *testing.T) { + t.Run("none", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice"}`), value) + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) + + t.Run("non-nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("nil pointer", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value *user + _, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + assert.EqualError(t, err, "value must be non-nil to extract extra properties") + }) + + t.Run("non-zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("zero value", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + var value user + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"age": float64(42)}, extraProperties) + }) + + t.Run("exclude", func(t *testing.T) { + type user struct { + Name string `json:"name"` + } + value := &user{ + Name: "alice", + } + extraProperties, err := ExtractExtraProperties([]byte(`{"name": "alice", "age": 42}`), value, "age") + require.NoError(t, err) + assert.Nil(t, extraProperties) + }) +} diff --git a/seed/go-sdk/go-global-headers/internal/http.go b/seed/go-sdk/go-global-headers/internal/http.go new file mode 100644 index 000000000000..77863752bb58 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/http.go @@ -0,0 +1,71 @@ +package internal + +import ( + "fmt" + "net/http" + "net/url" + "reflect" +) + +// HTTPClient is an interface for a subset of the *http.Client. +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// ResolveBaseURL resolves the base URL from the given arguments, +// preferring the first non-empty value. +func ResolveBaseURL(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +// EncodeURL encodes the given arguments into the URL, escaping +// values as needed. Pointer arguments are dereferenced before processing. +func EncodeURL(urlFormat string, args ...interface{}) string { + escapedArgs := make([]interface{}, 0, len(args)) + for _, arg := range args { + // Dereference the argument if it's a pointer + value := dereferenceArg(arg) + escapedArgs = append(escapedArgs, url.PathEscape(fmt.Sprintf("%v", value))) + } + return fmt.Sprintf(urlFormat, escapedArgs...) +} + +// dereferenceArg dereferences a pointer argument if necessary, returning the underlying value. +// If the argument is not a pointer or is nil, it returns the argument as-is. +func dereferenceArg(arg interface{}) interface{} { + if arg == nil { + return arg + } + + v := reflect.ValueOf(arg) + + // Keep dereferencing until we get to a non-pointer value or hit nil + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil + } + v = v.Elem() + } + + return v.Interface() +} + +// MergeHeaders merges the given headers together, where the right +// takes precedence over the left. +func MergeHeaders(left, right http.Header) http.Header { + for key, values := range right { + if len(values) > 1 { + left[key] = values + continue + } + if value := right.Get(key); value != "" { + left.Set(key, value) + } + } + return left +} diff --git a/seed/go-sdk/go-global-headers/internal/query.go b/seed/go-sdk/go-global-headers/internal/query.go new file mode 100644 index 000000000000..6a2aea6b64e6 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/query.go @@ -0,0 +1,369 @@ +package internal + +import ( + "encoding/base64" + "fmt" + "net/url" + "reflect" + "strings" + "time" + + "github.com/google/uuid" +) + +// RFC3339Milli is a time format string for RFC 3339 with millisecond precision. +// Go's time.RFC3339 omits fractional seconds and time.RFC3339Nano trims trailing +// zeros, so neither produces the fixed ".000" millisecond suffix that many APIs expect. +const RFC3339Milli = "2006-01-02T15:04:05.000Z07:00" + +var ( + bytesType = reflect.TypeOf([]byte{}) + queryEncoderType = reflect.TypeOf(new(QueryEncoder)).Elem() + timeType = reflect.TypeOf(time.Time{}) + uuidType = reflect.TypeOf(uuid.UUID{}) +) + +// QueryEncoder is an interface implemented by any type that wishes to encode +// itself into URL values in a non-standard way. +type QueryEncoder interface { + EncodeQueryValues(key string, v *url.Values) error +} + +// prepareValue handles common validation and unwrapping logic for both functions +func prepareValue(v interface{}) (reflect.Value, url.Values, error) { + values := make(url.Values) + val := reflect.ValueOf(v) + for val.Kind() == reflect.Ptr { + if val.IsNil() { + return reflect.Value{}, values, nil + } + val = val.Elem() + } + + if v == nil { + return reflect.Value{}, values, nil + } + + if val.Kind() != reflect.Struct { + return reflect.Value{}, nil, fmt.Errorf("query: Values() expects struct input. Got %v", val.Kind()) + } + + err := reflectValue(values, val, "") + if err != nil { + return reflect.Value{}, nil, err + } + + return val, values, nil +} + +// QueryValues encodes url.Values from request objects. +// +// Note: This type is inspired by Google's query encoding library, but +// supports far less customization and is tailored to fit this SDK's use case. +// +// Ref: https://github.com/google/go-querystring +func QueryValues(v interface{}) (url.Values, error) { + _, values, err := prepareValue(v) + return values, err +} + +// applyQueryDefaultsOnNilRequest reports whether query parameter defaults are applied when the +// request is nil. It is enabled by the applyQueryDefaultsOnNilRequest generator option, which +// emits an init function that sets it to true. +var applyQueryDefaultsOnNilRequest = false + +// QueryValuesWithDefaults encodes url.Values from request objects +// and default values, merging the defaults into the request. +// It's expected that the values of defaults are wire names. +func QueryValuesWithDefaults(v interface{}, defaults map[string]interface{}) (url.Values, error) { + val, values, err := prepareValue(v) + if err != nil { + return values, err + } + if !val.IsValid() { + if applyQueryDefaultsOnNilRequest { + // A nil request carries no explicit values, so every default applies. + for wireName, defaultVal := range defaults { + values.Set(wireName, valueString(reflect.ValueOf(defaultVal), tagOptions{}, reflect.StructField{})) + } + } + return values, nil + } + + // apply defaults to zero-value fields directly on the original struct + valType := val.Type() + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := valType.Field(i) + fieldName := fieldType.Name + + if fieldType.PkgPath != "" && !fieldType.Anonymous { + // Skip unexported fields. + continue + } + + // check if field is zero value and we have a default for it + if field.CanSet() && field.IsZero() { + tag := fieldType.Tag.Get("url") + if tag == "" || tag == "-" { + continue + } + wireName, _ := parseTag(tag) + if wireName == "" { + wireName = fieldName + } + if defaultVal, exists := defaults[wireName]; exists { + values.Set(wireName, valueString(reflect.ValueOf(defaultVal), tagOptions{}, reflect.StructField{})) + } + } + } + + return values, err +} + +// reflectValue populates the values parameter from the struct fields in val. +// Embedded structs are followed recursively (using the rules defined in the +// Values function documentation) breadth-first. +func reflectValue(values url.Values, val reflect.Value, scope string) error { + typ := val.Type() + for i := 0; i < typ.NumField(); i++ { + sf := typ.Field(i) + if sf.PkgPath != "" && !sf.Anonymous { + // Skip unexported fields. + continue + } + + sv := val.Field(i) + tag := sf.Tag.Get("url") + if tag == "" || tag == "-" { + continue + } + + name, opts := parseTag(tag) + if name == "" { + name = sf.Name + } + + if scope != "" { + name = scope + "[" + name + "]" + } + + if opts.Contains("omitempty") && isEmptyValue(sv) { + continue + } + + if sv.Type().Implements(queryEncoderType) { + // If sv is a nil pointer and the custom encoder is defined on a non-pointer + // method receiver, set sv to the zero value of the underlying type + if !reflect.Indirect(sv).IsValid() && sv.Type().Elem().Implements(queryEncoderType) { + sv = reflect.New(sv.Type().Elem()) + } + + m := sv.Interface().(QueryEncoder) + if err := m.EncodeQueryValues(name, &values); err != nil { + return err + } + continue + } + + // Recursively dereference pointers, but stop at nil pointers. + for sv.Kind() == reflect.Ptr { + if sv.IsNil() { + break + } + sv = sv.Elem() + } + + if sv.Type() == uuidType || sv.Type() == bytesType || sv.Type() == timeType { + values.Add(name, valueString(sv, opts, sf)) + continue + } + + if sv.Kind() == reflect.Slice || sv.Kind() == reflect.Array { + if sv.Len() == 0 { + // Skip if slice or array is empty. + continue + } + for i := 0; i < sv.Len(); i++ { + value := sv.Index(i) + if isStructPointer(value) && !value.IsNil() { + if err := reflectValue(values, value.Elem(), name); err != nil { + return err + } + } else { + values.Add(name, valueString(value, opts, sf)) + } + } + continue + } + + if sv.Kind() == reflect.Map { + if err := reflectMap(values, sv, name); err != nil { + return err + } + continue + } + + if sv.Kind() == reflect.Struct { + if err := reflectValue(values, sv, name); err != nil { + return err + } + continue + } + + values.Add(name, valueString(sv, opts, sf)) + } + + return nil +} + +// reflectMap handles map types specifically, generating query parameters in the format key[mapkey]=value +func reflectMap(values url.Values, val reflect.Value, scope string) error { + if val.IsNil() { + return nil + } + + iter := val.MapRange() + for iter.Next() { + k := iter.Key() + v := iter.Value() + + key := fmt.Sprint(k.Interface()) + paramName := scope + "[" + key + "]" + + for v.Kind() == reflect.Ptr { + if v.IsNil() { + break + } + v = v.Elem() + } + + for v.Kind() == reflect.Interface { + v = v.Elem() + } + + if v.Kind() == reflect.Map { + if err := reflectMap(values, v, paramName); err != nil { + return err + } + continue + } + + if v.Kind() == reflect.Struct { + if err := reflectValue(values, v, paramName); err != nil { + return err + } + continue + } + + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + if v.Len() == 0 { + continue + } + for i := 0; i < v.Len(); i++ { + value := v.Index(i) + if isStructPointer(value) && !value.IsNil() { + if err := reflectValue(values, value.Elem(), paramName); err != nil { + return err + } + } else { + values.Add(paramName, valueString(value, tagOptions{}, reflect.StructField{})) + } + } + continue + } + + values.Add(paramName, valueString(v, tagOptions{}, reflect.StructField{})) + } + + return nil +} + +// valueString returns the string representation of a value. +func valueString(v reflect.Value, opts tagOptions, sf reflect.StructField) string { + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return "" + } + v = v.Elem() + } + + if v.Type() == timeType { + t := v.Interface().(time.Time) + if format := sf.Tag.Get("format"); format == "date" { + return t.Format("2006-01-02") + } + return t.Format(RFC3339Milli) + } + + if v.Type() == uuidType { + u := v.Interface().(uuid.UUID) + return u.String() + } + + if v.Type() == bytesType { + b := v.Interface().([]byte) + return base64.StdEncoding.EncodeToString(b) + } + + return fmt.Sprint(v.Interface()) +} + +// isEmptyValue checks if a value should be considered empty for the purposes +// of omitting fields with the "omitempty" option. +func isEmptyValue(v reflect.Value) bool { + type zeroable interface { + IsZero() bool + } + + if !v.IsZero() { + if z, ok := v.Interface().(zeroable); ok { + return z.IsZero() + } + } + + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() + case reflect.Invalid, reflect.Complex64, reflect.Complex128, reflect.Chan, reflect.Func, reflect.Struct, reflect.UnsafePointer: + return false + } + + return false +} + +// isStructPointer returns true if the given reflect.Value is a pointer to a struct. +func isStructPointer(v reflect.Value) bool { + return v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Struct +} + +// tagOptions is the string following a comma in a struct field's "url" tag, or +// the empty string. It does not include the leading comma. +type tagOptions []string + +// parseTag splits a struct field's url tag into its name and comma-separated +// options. +func parseTag(tag string) (string, tagOptions) { + s := strings.Split(tag, ",") + return s[0], s[1:] +} + +// Contains checks whether the tagOptions contains the specified option. +func (o tagOptions) Contains(option string) bool { + for _, s := range o { + if s == option { + return true + } + } + return false +} diff --git a/seed/go-sdk/go-global-headers/internal/query_test.go b/seed/go-sdk/go-global-headers/internal/query_test.go new file mode 100644 index 000000000000..23ec3523b780 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/query_test.go @@ -0,0 +1,465 @@ +package internal + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQueryValues(t *testing.T) { + t.Run("empty optional", func(t *testing.T) { + type nested struct { + Value *string `json:"value,omitempty" url:"value,omitempty"` + } + type example struct { + Nested *nested `json:"nested,omitempty" url:"nested,omitempty"` + } + + values, err := QueryValues(&example{}) + require.NoError(t, err) + assert.Empty(t, values) + }) + + t.Run("empty required", func(t *testing.T) { + type nested struct { + Value *string `json:"value,omitempty" url:"value,omitempty"` + } + type example struct { + Required string `json:"required" url:"required"` + Nested *nested `json:"nested,omitempty" url:"nested,omitempty"` + } + + values, err := QueryValues(&example{}) + require.NoError(t, err) + assert.Equal(t, "required=", values.Encode()) + }) + + t.Run("allow multiple", func(t *testing.T) { + type example struct { + Values []string `json:"values" url:"values"` + } + + values, err := QueryValues( + &example{ + Values: []string{"foo", "bar", "baz"}, + }, + ) + require.NoError(t, err) + assert.Equal(t, "values=foo&values=bar&values=baz", values.Encode()) + }) + + t.Run("nested object", func(t *testing.T) { + type nested struct { + Value *string `json:"value,omitempty" url:"value,omitempty"` + } + type example struct { + Required string `json:"required" url:"required"` + Nested *nested `json:"nested,omitempty" url:"nested,omitempty"` + } + + nestedValue := "nestedValue" + values, err := QueryValues( + &example{ + Required: "requiredValue", + Nested: &nested{ + Value: &nestedValue, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "nested%5Bvalue%5D=nestedValue&required=requiredValue", values.Encode()) + }) + + t.Run("url unspecified", func(t *testing.T) { + type example struct { + Required string `json:"required" url:"required"` + NotFound string `json:"notFound"` + } + + values, err := QueryValues( + &example{ + Required: "requiredValue", + NotFound: "notFound", + }, + ) + require.NoError(t, err) + assert.Equal(t, "required=requiredValue", values.Encode()) + }) + + t.Run("url ignored", func(t *testing.T) { + type example struct { + Required string `json:"required" url:"required"` + NotFound string `json:"notFound" url:"-"` + } + + values, err := QueryValues( + &example{ + Required: "requiredValue", + NotFound: "notFound", + }, + ) + require.NoError(t, err) + assert.Equal(t, "required=requiredValue", values.Encode()) + }) + + t.Run("datetime", func(t *testing.T) { + type example struct { + DateTime time.Time `json:"dateTime" url:"dateTime"` + } + + values, err := QueryValues( + &example{ + DateTime: time.Date(1994, 3, 16, 12, 34, 56, 0, time.UTC), + }, + ) + require.NoError(t, err) + assert.Equal(t, "dateTime=1994-03-16T12%3A34%3A56.000Z", values.Encode()) + }) + + t.Run("date", func(t *testing.T) { + type example struct { + Date time.Time `json:"date" url:"date" format:"date"` + } + + values, err := QueryValues( + &example{ + Date: time.Date(1994, 3, 16, 12, 34, 56, 0, time.UTC), + }, + ) + require.NoError(t, err) + assert.Equal(t, "date=1994-03-16", values.Encode()) + }) + + t.Run("optional time", func(t *testing.T) { + type example struct { + Date *time.Time `json:"date,omitempty" url:"date,omitempty" format:"date"` + } + + values, err := QueryValues( + &example{}, + ) + require.NoError(t, err) + assert.Empty(t, values.Encode()) + }) + + t.Run("omitempty with non-pointer zero value", func(t *testing.T) { + type enum string + + type example struct { + Enum enum `json:"enum,omitempty" url:"enum,omitempty"` + } + + values, err := QueryValues( + &example{}, + ) + require.NoError(t, err) + assert.Empty(t, values.Encode()) + }) + + t.Run("object array", func(t *testing.T) { + type object struct { + Key string `json:"key" url:"key"` + Value string `json:"value" url:"value"` + } + type example struct { + Objects []*object `json:"objects,omitempty" url:"objects,omitempty"` + } + + values, err := QueryValues( + &example{ + Objects: []*object{ + { + Key: "hello", + Value: "world", + }, + { + Key: "foo", + Value: "bar", + }, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "objects%5Bkey%5D=hello&objects%5Bkey%5D=foo&objects%5Bvalue%5D=world&objects%5Bvalue%5D=bar", values.Encode()) + }) + + t.Run("map", func(t *testing.T) { + type request struct { + Metadata map[string]interface{} `json:"metadata" url:"metadata"` + } + values, err := QueryValues( + &request{ + Metadata: map[string]interface{}{ + "foo": "bar", + "baz": "qux", + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "metadata%5Bbaz%5D=qux&metadata%5Bfoo%5D=bar", values.Encode()) + }) + + t.Run("nested map", func(t *testing.T) { + type request struct { + Metadata map[string]interface{} `json:"metadata" url:"metadata"` + } + values, err := QueryValues( + &request{ + Metadata: map[string]interface{}{ + "inner": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "metadata%5Binner%5D%5Bfoo%5D=bar", values.Encode()) + }) + + t.Run("nested map array", func(t *testing.T) { + type request struct { + Metadata map[string]interface{} `json:"metadata" url:"metadata"` + } + values, err := QueryValues( + &request{ + Metadata: map[string]interface{}{ + "inner": []string{ + "one", + "two", + "three", + }, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "metadata%5Binner%5D=one&metadata%5Binner%5D=two&metadata%5Binner%5D=three", values.Encode()) + }) +} + +func TestQueryValuesWithDefaults(t *testing.T) { + t.Run("apply defaults to zero values", func(t *testing.T) { + type example struct { + Name string `json:"name" url:"name"` + Age int `json:"age" url:"age"` + Enabled bool `json:"enabled" url:"enabled"` + } + + defaults := map[string]interface{}{ + "name": "default-name", + "age": 25, + "enabled": true, + } + + values, err := QueryValuesWithDefaults(&example{}, defaults) + require.NoError(t, err) + assert.Equal(t, "age=25&enabled=true&name=default-name", values.Encode()) + }) + + t.Run("preserve non-zero values over defaults", func(t *testing.T) { + type example struct { + Name string `json:"name" url:"name"` + Age int `json:"age" url:"age"` + Enabled bool `json:"enabled" url:"enabled"` + } + + defaults := map[string]interface{}{ + "name": "default-name", + "age": 25, + "enabled": true, + } + + values, err := QueryValuesWithDefaults(&example{ + Name: "actual-name", + Age: 30, + // Enabled remains false (zero value), should get default + }, defaults) + require.NoError(t, err) + assert.Equal(t, "age=30&enabled=true&name=actual-name", values.Encode()) + }) + + t.Run("ignore defaults for fields not in struct", func(t *testing.T) { + type example struct { + Name string `json:"name" url:"name"` + Age int `json:"age" url:"age"` + } + + defaults := map[string]interface{}{ + "name": "default-name", + "age": 25, + "nonexistent": "should-be-ignored", + } + + values, err := QueryValuesWithDefaults(&example{}, defaults) + require.NoError(t, err) + assert.Equal(t, "age=25&name=default-name", values.Encode()) + }) + + t.Run("type conversion for compatible defaults", func(t *testing.T) { + type example struct { + Count int64 `json:"count" url:"count"` + Rate float64 `json:"rate" url:"rate"` + Message string `json:"message" url:"message"` + } + + defaults := map[string]interface{}{ + "count": int(100), // int -> int64 conversion + "rate": float32(2.5), // float32 -> float64 conversion + "message": "hello", // string -> string (no conversion needed) + } + + values, err := QueryValuesWithDefaults(&example{}, defaults) + require.NoError(t, err) + assert.Equal(t, "count=100&message=hello&rate=2.5", values.Encode()) + }) + + t.Run("mixed with pointer fields and omitempty", func(t *testing.T) { + type example struct { + Required string `json:"required" url:"required"` + Optional *string `json:"optional,omitempty" url:"optional,omitempty"` + Count int `json:"count,omitempty" url:"count,omitempty"` + } + + defaultOptional := "default-optional" + defaults := map[string]interface{}{ + "required": "default-required", + "optional": &defaultOptional, // pointer type + "count": 42, + } + + values, err := QueryValuesWithDefaults(&example{ + Required: "custom-required", // should override default + // Optional is nil, should get default + // Count is 0, should get default + }, defaults) + require.NoError(t, err) + assert.Equal(t, "count=42&optional=default-optional&required=custom-required", values.Encode()) + }) + + t.Run("override non-zero defaults with explicit zero values", func(t *testing.T) { + type example struct { + Name *string `json:"name" url:"name"` + Age *int `json:"age" url:"age"` + Enabled *bool `json:"enabled" url:"enabled"` + } + + defaults := map[string]interface{}{ + "name": "default-name", + "age": 25, + "enabled": true, + } + + // first, test that a properly empty request is overridden: + { + values, err := QueryValuesWithDefaults(&example{}, defaults) + require.NoError(t, err) + assert.Equal(t, "age=25&enabled=true&name=default-name", values.Encode()) + } + + // second, test that a request that contains zeros is not overridden: + var ( + name = "" + age = 0 + enabled = false + ) + values, err := QueryValuesWithDefaults(&example{ + Name: &name, // explicit empty string should override default + Age: &age, // explicit zero should override default + Enabled: &enabled, // explicit false should override default + }, defaults) + require.NoError(t, err) + assert.Equal(t, "age=0&enabled=false&name=", values.Encode()) + }) + + t.Run("nil input returns empty values", func(t *testing.T) { + defer setApplyQueryDefaultsOnNilRequest(false)() + + defaults := map[string]any{ + "name": "default-name", + "age": 25, + } + + // Test with nil + values, err := QueryValuesWithDefaults(nil, defaults) + require.NoError(t, err) + assert.Empty(t, values) + + // Test with nil pointer + type example struct { + Name string `json:"name" url:"name"` + } + var nilPtr *example + values, err = QueryValuesWithDefaults(nilPtr, defaults) + require.NoError(t, err) + assert.Empty(t, values) + }) + + t.Run("nil input applies defaults when enabled", func(t *testing.T) { + defer setApplyQueryDefaultsOnNilRequest(true)() + + defaults := map[string]any{ + "name": "default-name", + "age": 25, + } + + // Test with nil + values, err := QueryValuesWithDefaults(nil, defaults) + require.NoError(t, err) + assert.Equal(t, "age=25&name=default-name", values.Encode()) + + // Test with nil pointer + type example struct { + Name string `json:"name" url:"name"` + } + var nilPtr *example + values, err = QueryValuesWithDefaults(nilPtr, defaults) + require.NoError(t, err) + assert.Equal(t, "age=25&name=default-name", values.Encode()) + }) + + t.Run("nil input without defaults returns empty values when enabled", func(t *testing.T) { + defer setApplyQueryDefaultsOnNilRequest(true)() + + type example struct { + Name string `json:"name" url:"name"` + } + var nilPtr *example + + values, err := QueryValuesWithDefaults(nilPtr, nil) + require.NoError(t, err) + assert.Empty(t, values) + }) + + t.Run("nil input matches zero-value struct when enabled", func(t *testing.T) { + defer setApplyQueryDefaultsOnNilRequest(true)() + + type example struct { + IncludeTotals *bool `json:"include_totals,omitempty" url:"include_totals,omitempty"` + } + defaults := map[string]any{ + "include_totals": true, + } + + var nilPtr *example + nilValues, err := QueryValuesWithDefaults(nilPtr, defaults) + require.NoError(t, err) + + zeroValues, err := QueryValuesWithDefaults(&example{}, defaults) + require.NoError(t, err) + + assert.Equal(t, "include_totals=true", nilValues.Encode()) + assert.Equal(t, zeroValues.Encode(), nilValues.Encode()) + }) +} + +// setApplyQueryDefaultsOnNilRequest sets the flag emitted by the +// applyQueryDefaultsOnNilRequest generator option, and returns a +// function that restores its previous value. +func setApplyQueryDefaultsOnNilRequest(value bool) func() { + previous := applyQueryDefaultsOnNilRequest + applyQueryDefaultsOnNilRequest = value + return func() { + applyQueryDefaultsOnNilRequest = previous + } +} diff --git a/seed/go-sdk/go-global-headers/internal/retrier.go b/seed/go-sdk/go-global-headers/internal/retrier.go new file mode 100644 index 000000000000..5ee380c7df98 --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/retrier.go @@ -0,0 +1,268 @@ +package internal + +import ( + "crypto/rand" + "math/big" + "net/http" + "strconv" + "time" +) + +const ( + defaultRetryAttempts = 2 + minRetryDelay = 1000 * time.Millisecond + maxRetryDelay = 60000 * time.Millisecond +) + +// RetryOption adapts the behavior the *Retrier. +type RetryOption func(*retryOptions) + +// RetryFunc is a retryable HTTP function call (i.e. *http.Client.Do). +type RetryFunc func(*http.Request) (*http.Response, error) + +// WithMaxAttempts configures the maximum number of attempts +// of the *Retrier. +func WithMaxAttempts(attempts uint) RetryOption { + return func(opts *retryOptions) { + opts.attempts = attempts + } +} + +// WithDisableRetries disables retry attempts entirely. The request is issued +// exactly once. Distinct from WithMaxAttempts(0), which falls through to the +// default. +func WithDisableRetries() RetryOption { + return func(opts *retryOptions) { + opts.disabled = true + } +} + +func buildRetryOptions(maxAttempts uint, disableRetries bool) []RetryOption { + var opts []RetryOption + if maxAttempts > 0 { + opts = append(opts, WithMaxAttempts(maxAttempts)) + } + if disableRetries { + opts = append(opts, WithDisableRetries()) + } + return opts +} + +// Retrier retries failed requests a configurable number of times with an +// exponential back-off between each retry. +type Retrier struct { + attempts uint +} + +// NewRetrier constructs a new *Retrier with the given options, if any. +func NewRetrier(opts ...RetryOption) *Retrier { + options := new(retryOptions) + for _, opt := range opts { + opt(options) + } + attempts := uint(defaultRetryAttempts) + if options.attempts > 0 { + attempts = options.attempts + } + return &Retrier{ + attempts: attempts, + } +} + +// Run issues the request and, upon failure, retries the request if possible. +// +// The request will be retried as long as the request is deemed retryable and the +// number of retry attempts has not grown larger than the configured retry limit. +func (r *Retrier) Run( + fn RetryFunc, + request *http.Request, + errorDecoder ErrorDecoder, + opts ...RetryOption, +) (*http.Response, error) { + options := new(retryOptions) + for _, opt := range opts { + opt(options) + } + maxRetryAttempts := r.attempts + if options.attempts > 0 { + maxRetryAttempts = options.attempts + } + if options.disabled { + maxRetryAttempts = 1 + } + var ( + retryAttempt uint + previousError error + ) + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt, + previousError, + ) +} + +func (r *Retrier) run( + fn RetryFunc, + request *http.Request, + errorDecoder ErrorDecoder, + maxRetryAttempts uint, + retryAttempt uint, + previousError error, +) (*http.Response, error) { + if retryAttempt >= maxRetryAttempts { + return nil, previousError + } + + // If the call has been cancelled, don't issue the request. + if err := request.Context().Err(); err != nil { + return nil, err + } + + // Reset the request body for retries since the body may have already been read. + if retryAttempt > 0 && request.GetBody != nil { + requestBody, err := request.GetBody() + if err != nil { + return nil, err + } + request.Body = requestBody + } + + response, err := fn(request) + if err != nil { + return nil, err + } + + if r.shouldRetry(response) { + defer func() { _ = response.Body.Close() }() + + delay, err := r.retryDelay(response, retryAttempt) + if err != nil { + return nil, err + } + + time.Sleep(delay) + + body, err := decompressedResponseBody(response) + if err != nil { + return nil, err + } + + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt+1, + decodeError(response, body, errorDecoder), + ) + } + + return response, nil +} + +// shouldRetry returns true if the request should be retried based on the given +// response status code. +func (r *Retrier) shouldRetry(response *http.Response) bool { + return response.StatusCode == http.StatusTooManyRequests || + response.StatusCode == http.StatusRequestTimeout || + response.StatusCode >= http.StatusInternalServerError +} + +// retryDelay calculates the delay time based on response headers, +// falling back to exponential backoff if no headers are present. +func (r *Retrier) retryDelay(response *http.Response, retryAttempt uint) (time.Duration, error) { + // Check for Retry-After header first (RFC 7231), applying no jitter + if retryAfter := response.Header.Get("Retry-After"); retryAfter != "" { + // Parse as number of seconds... + if seconds, err := strconv.Atoi(retryAfter); err == nil { + delay := time.Duration(seconds) * time.Second + if delay > 0 { + if delay > maxRetryDelay { + delay = maxRetryDelay + } + return delay, nil + } + } + + // ...or as an HTTP date; both are valid + if retryTime, err := time.Parse(time.RFC1123, retryAfter); err == nil { + delay := time.Until(retryTime) + if delay > 0 { + if delay > maxRetryDelay { + delay = maxRetryDelay + } + return delay, nil + } + } + } + + // Then check for industry-standard X-RateLimit-Reset header, applying positive jitter + if rateLimitReset := response.Header.Get("X-RateLimit-Reset"); rateLimitReset != "" { + if resetTimestamp, err := strconv.ParseInt(rateLimitReset, 10, 64); err == nil { + // Assume Unix timestamp in seconds + resetTime := time.Unix(resetTimestamp, 0) + delay := time.Until(resetTime) + if delay > 0 { + if delay > maxRetryDelay { + delay = maxRetryDelay + } + return r.addPositiveJitter(delay) + } + } + } + + // Fall back to exponential backoff + return r.exponentialBackoff(retryAttempt) +} + +// exponentialBackoff calculates the delay time based on the retry attempt +// and applies symmetric jitter (±10% around the delay). +func (r *Retrier) exponentialBackoff(retryAttempt uint) (time.Duration, error) { + if retryAttempt > 63 { // 2^63+ would overflow uint64 + retryAttempt = 63 + } + + delay := minRetryDelay << retryAttempt + if delay > maxRetryDelay { + delay = maxRetryDelay + } + + return r.addSymmetricJitter(delay) +} + +// addJitterWithRange applies jitter to the given delay. +// minPercent and maxPercent define the jitter range (e.g., 100, 120 for +0% to +20%). +func (r *Retrier) addJitterWithRange(delay time.Duration, minPercent, maxPercent int) (time.Duration, error) { + jitterRange := big.NewInt(int64(delay * time.Duration(maxPercent-minPercent) / 100)) + jitter, err := rand.Int(rand.Reader, jitterRange) + if err != nil { + return 0, err + } + + jitteredDelay := delay + time.Duration(jitter.Int64()) + delay*time.Duration(minPercent-100)/100 + if jitteredDelay < minRetryDelay { + jitteredDelay = minRetryDelay + } + if jitteredDelay > maxRetryDelay { + jitteredDelay = maxRetryDelay + } + return jitteredDelay, nil +} + +// addPositiveJitter applies positive jitter to the given delay (100%-120% range). +func (r *Retrier) addPositiveJitter(delay time.Duration) (time.Duration, error) { + return r.addJitterWithRange(delay, 100, 120) +} + +// addSymmetricJitter applies symmetric jitter to the given delay (90%-110% range). +func (r *Retrier) addSymmetricJitter(delay time.Duration) (time.Duration, error) { + return r.addJitterWithRange(delay, 90, 110) +} + +type retryOptions struct { + attempts uint + disabled bool +} diff --git a/seed/go-sdk/go-global-headers/internal/retrier_test.go b/seed/go-sdk/go-global-headers/internal/retrier_test.go new file mode 100644 index 000000000000..0c0171933dda --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/retrier_test.go @@ -0,0 +1,385 @@ +package internal + +import ( + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-global-headers/fern/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type RetryTestCase struct { + description string + + giveAttempts uint + giveStatusCodes []int + giveResponse *InternalTestResponse + + wantResponse *InternalTestResponse + wantError *core.APIError +} + +func TestRetrier(t *testing.T) { + tests := []*RetryTestCase{ + { + description: "retry request succeeds after multiple failures", + giveAttempts: 3, + giveStatusCodes: []int{ + http.StatusServiceUnavailable, + http.StatusServiceUnavailable, + http.StatusOK, + }, + giveResponse: &InternalTestResponse{ + Id: "1", + }, + wantResponse: &InternalTestResponse{ + Id: "1", + }, + }, + { + description: "retry request fails if MaxAttempts is exceeded", + giveAttempts: 3, + giveStatusCodes: []int{ + http.StatusRequestTimeout, + http.StatusRequestTimeout, + http.StatusRequestTimeout, + http.StatusOK, + }, + wantError: &core.APIError{ + StatusCode: http.StatusRequestTimeout, + }, + }, + { + description: "retry durations increase exponentially and stay within the min and max delay values", + giveAttempts: 4, + giveStatusCodes: []int{ + http.StatusServiceUnavailable, + http.StatusServiceUnavailable, + http.StatusServiceUnavailable, + http.StatusOK, + }, + }, + { + description: "retry does not occur on status code 404", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusNotFound, http.StatusOK}, + wantError: &core.APIError{ + StatusCode: http.StatusNotFound, + }, + }, + { + description: "retries occur on status code 429", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusTooManyRequests, http.StatusOK}, + }, + { + description: "retries occur on status code 408", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusRequestTimeout, http.StatusOK}, + }, + { + description: "retries occur on status code 500", + giveAttempts: 2, + giveStatusCodes: []int{http.StatusInternalServerError, http.StatusOK}, + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + var ( + test = tc + server = newTestRetryServer(t, test) + client = server.Client() + ) + + t.Parallel() + + caller := NewCaller( + &CallerParams{ + Client: client, + }, + ) + + var response *InternalTestResponse + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Request: &InternalTestRequest{}, + Response: &response, + MaxAttempts: test.giveAttempts, + ResponseIsOptional: true, + }, + ) + + if test.wantError != nil { + require.IsType(t, err, &core.APIError{}) + expectedErrorCode := test.wantError.StatusCode + actualErrorCode := err.(*core.APIError).StatusCode + assert.Equal(t, expectedErrorCode, actualErrorCode) + return + } + + require.NoError(t, err) + assert.Equal(t, test.wantResponse, response) + }) + } +} + +func TestRetryExhaustionWithGzipErrorResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "gzip", r.Header.Get("Accept-Encoding")) + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusInternalServerError) + gzipWriter := gzip.NewWriter(w) + _, err := gzipWriter.Write([]byte("retry failed")) + require.NoError(t, err) + require.NoError(t, gzipWriter.Close()) + })) + defer server.Close() + + caller := NewCaller(&CallerParams{ + Client: server.Client(), + }) + + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Headers: http.Header{ + "Accept-Encoding": []string{"gzip"}, + }, + MaxAttempts: 1, + }, + ) + + require.IsType(t, &core.APIError{}, err) + require.EqualError(t, err, "500: retry failed") +} + +// newTestRetryServer returns a new *httptest.Server configured with the +// given test parameters, suitable for testing retries. +func newTestRetryServer(t *testing.T, tc *RetryTestCase) *httptest.Server { + var index int + timestamps := make([]time.Time, 0, len(tc.giveStatusCodes)) + + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + timestamps = append(timestamps, time.Now()) + if index > 0 && index < len(expectedRetryDurations) { + // Ensure that the duration between retries increases exponentially, + // and that it is within the minimum and maximum retry delay values. + actualDuration := timestamps[index].Sub(timestamps[index-1]) + expectedDurationMin := expectedRetryDurations[index-1] * 50 / 100 + expectedDurationMax := expectedRetryDurations[index-1] * 150 / 100 + assert.True( + t, + actualDuration >= expectedDurationMin && actualDuration <= expectedDurationMax, + "expected duration to be in range [%v, %v], got %v", + expectedDurationMin, + expectedDurationMax, + actualDuration, + ) + assert.LessOrEqual( + t, + actualDuration, + maxRetryDelay, + "expected duration to be less than the maxRetryDelay (%v), got %v", + maxRetryDelay, + actualDuration, + ) + assert.GreaterOrEqual( + t, + actualDuration, + minRetryDelay, + "expected duration to be greater than the minRetryDelay (%v), got %v", + minRetryDelay, + actualDuration, + ) + } + + request := new(InternalTestRequest) + bytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(bytes, request)) + require.LessOrEqual(t, index, len(tc.giveStatusCodes)) + + statusCode := tc.giveStatusCodes[index] + + w.WriteHeader(statusCode) + + if tc.giveResponse != nil && statusCode == http.StatusOK { + bytes, err = json.Marshal(tc.giveResponse) + require.NoError(t, err) + _, err = w.Write(bytes) + require.NoError(t, err) + } + + index++ + }, + ), + ) +} + +// expectedRetryDurations holds an array of calculated retry durations, +// where the index of the array should correspond to the retry attempt. +// +// Values are calculated based off of `minRetryDelay * 2^i`. +var expectedRetryDurations = []time.Duration{ + 1000 * time.Millisecond, // 500ms * 2^1 = 1000ms + 2000 * time.Millisecond, // 500ms * 2^2 = 2000ms + 4000 * time.Millisecond, // 500ms * 2^3 = 4000ms + 8000 * time.Millisecond, // 500ms * 2^4 = 8000ms +} + +func TestRetryWithRequestBody(t *testing.T) { + // This test verifies that POST requests with a body are properly retried. + // The request body should be re-sent on each retry attempt. + expectedBody := `{"id":"test-id"}` + var requestBodies []string + var requestCount int + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + requestBodies = append(requestBodies, string(bodyBytes)) + + if requestCount == 1 { + // First request - return retryable error + w.WriteHeader(http.StatusServiceUnavailable) + return + } + // Second request - return success + w.WriteHeader(http.StatusOK) + response := &InternalTestResponse{Id: "success"} + bytes, _ := json.Marshal(response) + _, _ = w.Write(bytes) + })) + defer server.Close() + + caller := NewCaller(&CallerParams{ + Client: server.Client(), + }) + + var response *InternalTestResponse + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodPost, + Request: &InternalTestRequest{Id: "test-id"}, + Response: &response, + MaxAttempts: 2, + ResponseIsOptional: true, + }, + ) + + require.NoError(t, err) + require.Equal(t, 2, requestCount, "Expected exactly 2 requests") + require.Len(t, requestBodies, 2, "Expected 2 request bodies to be captured") + + // Both requests should have the same non-empty body + assert.Equal(t, expectedBody, requestBodies[0], "First request body should match expected") + assert.Equal(t, expectedBody, requestBodies[1], "Second request body should match expected (retry should re-send body)") +} + +func TestRetryDelayTiming(t *testing.T) { + tests := []struct { + name string + headerName string + headerValueFunc func() string + expectedMinMs int64 + expectedMaxMs int64 + }{ + { + name: "retry-after with seconds value", + headerName: "retry-after", + headerValueFunc: func() string { + return "1" + }, + expectedMinMs: 500, + expectedMaxMs: 1500, + }, + { + name: "retry-after with HTTP date", + headerName: "retry-after", + headerValueFunc: func() string { + return time.Now().Add(3 * time.Second).Format(time.RFC1123) + }, + expectedMinMs: 1500, + expectedMaxMs: 4500, + }, + { + name: "x-ratelimit-reset with future timestamp", + headerName: "x-ratelimit-reset", + headerValueFunc: func() string { + return fmt.Sprintf("%d", time.Now().Add(3*time.Second).Unix()) + }, + expectedMinMs: 1500, + expectedMaxMs: 4500, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var timestamps []time.Time + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + timestamps = append(timestamps, time.Now()) + if len(timestamps) == 1 { + // First request - return retryable error with header + w.Header().Set(tt.headerName, tt.headerValueFunc()) + w.WriteHeader(http.StatusTooManyRequests) + } else { + // Second request - return success + w.WriteHeader(http.StatusOK) + response := &InternalTestResponse{Id: "success"} + bytes, _ := json.Marshal(response) + _, _ = w.Write(bytes) + } + })) + defer server.Close() + + caller := NewCaller(&CallerParams{ + Client: server.Client(), + }) + + var response *InternalTestResponse + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Request: &InternalTestRequest{}, + Response: &response, + MaxAttempts: 2, + ResponseIsOptional: true, + }, + ) + + require.NoError(t, err) + require.Len(t, timestamps, 2, "Expected exactly 2 requests") + + actualDelayMs := timestamps[1].Sub(timestamps[0]).Milliseconds() + + assert.GreaterOrEqual(t, actualDelayMs, tt.expectedMinMs, + "Actual delay %dms should be >= expected min %dms", actualDelayMs, tt.expectedMinMs) + assert.LessOrEqual(t, actualDelayMs, tt.expectedMaxMs, + "Actual delay %dms should be <= expected max %dms", actualDelayMs, tt.expectedMaxMs) + }) + } +} diff --git a/seed/go-sdk/go-global-headers/internal/stringer.go b/seed/go-sdk/go-global-headers/internal/stringer.go new file mode 100644 index 000000000000..312801851e0e --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/stringer.go @@ -0,0 +1,13 @@ +package internal + +import "encoding/json" + +// StringifyJSON returns a pretty JSON string representation of +// the given value. +func StringifyJSON(value interface{}) (string, error) { + bytes, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} diff --git a/seed/go-sdk/go-global-headers/internal/time.go b/seed/go-sdk/go-global-headers/internal/time.go new file mode 100644 index 000000000000..d87a50b1073e --- /dev/null +++ b/seed/go-sdk/go-global-headers/internal/time.go @@ -0,0 +1,385 @@ +package internal + +import ( + "encoding/json" + "fmt" + "time" +) + +const dateFormat = "2006-01-02" + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date (e.g. 2006-01-02). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type Date struct { + t *time.Time +} + +// NewDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewDate(t time.Time) *Date { + return &Date{t: &t} +} + +// NewOptionalDate returns a new *Date. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDate(t *time.Time) *Date { + if t == nil { + return nil + } + return &Date{t: t} +} + +// Time returns the Date's underlying time, if any. If the +// date is nil, the zero value is returned. +func (d *Date) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the Date's underlying time.Time, if any. +func (d *Date) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *Date) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(dateFormat)) +} + +func (d *Date) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + parsedTime, err := time.Parse(dateFormat, raw) + if err != nil { + return err + } + + *d = Date{t: &parsedTime} + return nil +} + +// NewDateList returns a slice of *Date for the given times. If the given +// slice is nil, nil will be returned. +func NewDateList(times []time.Time) []*Date { + if times == nil { + return nil + } + dates := make([]*Date, 0, len(times)) + for _, t := range times { + dates = append(dates, NewDate(t)) + } + return dates +} + +// NewDateMap returns a map of *Date for the given times. If the given +// map is nil, nil will be returned. +func NewDateMap(times map[string]time.Time) map[string]*Date { + if times == nil { + return nil + } + dates := make(map[string]*Date, len(times)) + for key, t := range times { + dates[key] = NewDate(t) + } + return dates +} + +// TimesFromDateList returns the underlying times of the given dates. If the +// given slice is nil, nil will be returned. +func TimesFromDateList(dates []*Date) []time.Time { + if dates == nil { + return nil + } + times := make([]time.Time, 0, len(dates)) + for _, date := range dates { + times = append(times, date.Time()) + } + return times +} + +// TimesFromDateMap returns the underlying times of the given dates. If the +// given map is nil, nil will be returned. +func TimesFromDateMap(dates map[string]*Date) map[string]time.Time { + if dates == nil { + return nil + } + times := make(map[string]time.Time, len(dates)) + for key, date := range dates { + times[key] = date.Time() + } + return times +} + +// NewDateListFromPtr returns a slice of *Date for the given times. If the +// given pointer is nil, nil will be returned. +func NewDateListFromPtr(times *[]time.Time) []*Date { + if times == nil { + return nil + } + return NewDateList(*times) +} + +// NewDateMapFromPtr returns a map of *Date for the given times. If the given +// pointer is nil, nil will be returned. +func NewDateMapFromPtr(times *map[string]time.Time) map[string]*Date { + if times == nil { + return nil + } + return NewDateMap(*times) +} + +// TimesPtrFromDateList returns a pointer to the underlying times of the given +// dates. If the given slice is nil, nil will be returned. +func TimesPtrFromDateList(dates []*Date) *[]time.Time { + if dates == nil { + return nil + } + times := TimesFromDateList(dates) + return × +} + +// TimesPtrFromDateMap returns a pointer to the underlying times of the given +// dates. If the given map is nil, nil will be returned. +func TimesPtrFromDateMap(dates map[string]*Date) *map[string]time.Time { + if dates == nil { + return nil + } + times := TimesFromDateMap(dates) + return × +} + +// DateTime wraps time.Time and adapts its JSON representation +// to conform to a RFC3339 date-time (e.g. 2017-07-21T17:32:28Z). +// +// Ref: https://ijmacd.github.io/rfc3339-iso8601 +type DateTime struct { + t *time.Time +} + +// NewDateTime returns a new *DateTime. +func NewDateTime(t time.Time) *DateTime { + return &DateTime{t: &t} +} + +// NewOptionalDateTime returns a new *DateTime. If the given time.Time +// is nil, nil will be returned. +func NewOptionalDateTime(t *time.Time) *DateTime { + if t == nil { + return nil + } + return &DateTime{t: t} +} + +// Time returns the DateTime's underlying time, if any. If the +// date-time is nil, the zero value is returned. +func (d *DateTime) Time() time.Time { + if d == nil || d.t == nil { + return time.Time{} + } + return *d.t +} + +// TimePtr returns a pointer to the DateTime's underlying time.Time, if any. +func (d *DateTime) TimePtr() *time.Time { + if d == nil || d.t == nil { + return nil + } + if d.t.IsZero() { + return nil + } + return d.t +} + +func (d *DateTime) MarshalJSON() ([]byte, error) { + if d == nil || d.t == nil { + return nil, nil + } + return json.Marshal(d.t.Format(time.RFC3339)) +} + +func (d *DateTime) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + // If the value is not a string, check if it is a number (unix epoch seconds). + var epoch int64 + if numErr := json.Unmarshal(data, &epoch); numErr == nil { + t := time.Unix(epoch, 0).UTC() + *d = DateTime{t: &t} + return nil + } + return err + } + + // Try RFC3339Nano first (superset of RFC3339, supports fractional seconds). + parsedTime, err := time.Parse(time.RFC3339Nano, raw) + if err == nil { + *d = DateTime{t: &parsedTime} + return nil + } + rfc3339NanoErr := err + + // Fall back to ISO 8601 with fractional seconds, without timezone (assume UTC). + parsedTime, err = time.Parse("2006-01-02T15:04:05.999999999", raw) + if err == nil { + parsedTime = parsedTime.UTC() + *d = DateTime{t: &parsedTime} + return nil + } + + // Fall back to ISO 8601 without timezone (assume UTC). + parsedTime, err = time.Parse("2006-01-02T15:04:05", raw) + if err == nil { + parsedTime = parsedTime.UTC() + *d = DateTime{t: &parsedTime} + return nil + } + iso8601Err := err + + // Fall back to space-separated datetime with fractional seconds and timezone offset. + parsedTime, err = time.Parse("2006-01-02 15:04:05.999999999Z07:00", raw) + if err == nil { + *d = DateTime{t: &parsedTime} + return nil + } + + // Fall back to space-separated datetime with timezone offset (e.g. "2025-02-15 10:30:00+00:00"). + parsedTime, err = time.Parse("2006-01-02 15:04:05Z07:00", raw) + if err == nil { + *d = DateTime{t: &parsedTime} + return nil + } + spaceTzErr := err + + // Fall back to space-separated datetime with fractional seconds, no timezone (assume UTC). + parsedTime, err = time.Parse("2006-01-02 15:04:05.999999999", raw) + if err == nil { + parsedTime = parsedTime.UTC() + *d = DateTime{t: &parsedTime} + return nil + } + + // Fall back to space-separated datetime without timezone (assume UTC). + parsedTime, err = time.Parse("2006-01-02 15:04:05", raw) + if err == nil { + parsedTime = parsedTime.UTC() + *d = DateTime{t: &parsedTime} + return nil + } + spaceNoTzErr := err + + // Fall back to date-only format. + parsedTime, err = time.Parse("2006-01-02", raw) + if err == nil { + parsedTime = parsedTime.UTC() + *d = DateTime{t: &parsedTime} + return nil + } + dateOnlyErr := err + + return fmt.Errorf("unable to parse datetime string %q: tried RFC3339Nano (%v), ISO8601 (%v), space-separated with tz (%v), space-separated (%v), date-only (%v)", raw, rfc3339NanoErr, iso8601Err, spaceTzErr, spaceNoTzErr, dateOnlyErr) +} + +// NewDateTimeList returns a slice of *DateTime for the given times. If the +// given slice is nil, nil will be returned. +func NewDateTimeList(times []time.Time) []*DateTime { + if times == nil { + return nil + } + dateTimes := make([]*DateTime, 0, len(times)) + for _, t := range times { + dateTimes = append(dateTimes, NewDateTime(t)) + } + return dateTimes +} + +// NewDateTimeMap returns a map of *DateTime for the given times. If the +// given map is nil, nil will be returned. +func NewDateTimeMap(times map[string]time.Time) map[string]*DateTime { + if times == nil { + return nil + } + dateTimes := make(map[string]*DateTime, len(times)) + for key, t := range times { + dateTimes[key] = NewDateTime(t) + } + return dateTimes +} + +// TimesFromDateTimeList returns the underlying times of the given date-times. +// If the given slice is nil, nil will be returned. +func TimesFromDateTimeList(dateTimes []*DateTime) []time.Time { + if dateTimes == nil { + return nil + } + times := make([]time.Time, 0, len(dateTimes)) + for _, dateTime := range dateTimes { + times = append(times, dateTime.Time()) + } + return times +} + +// TimesFromDateTimeMap returns the underlying times of the given date-times. +// If the given map is nil, nil will be returned. +func TimesFromDateTimeMap(dateTimes map[string]*DateTime) map[string]time.Time { + if dateTimes == nil { + return nil + } + times := make(map[string]time.Time, len(dateTimes)) + for key, dateTime := range dateTimes { + times[key] = dateTime.Time() + } + return times +} + +// NewDateTimeListFromPtr returns a slice of *DateTime for the given times. If +// the given pointer is nil, nil will be returned. +func NewDateTimeListFromPtr(times *[]time.Time) []*DateTime { + if times == nil { + return nil + } + return NewDateTimeList(*times) +} + +// NewDateTimeMapFromPtr returns a map of *DateTime for the given times. If the +// given pointer is nil, nil will be returned. +func NewDateTimeMapFromPtr(times *map[string]time.Time) map[string]*DateTime { + if times == nil { + return nil + } + return NewDateTimeMap(*times) +} + +// TimesPtrFromDateTimeList returns a pointer to the underlying times of the +// given date-times. If the given slice is nil, nil will be returned. +func TimesPtrFromDateTimeList(dateTimes []*DateTime) *[]time.Time { + if dateTimes == nil { + return nil + } + times := TimesFromDateTimeList(dateTimes) + return × +} + +// TimesPtrFromDateTimeMap returns a pointer to the underlying times of the +// given date-times. If the given map is nil, nil will be returned. +func TimesPtrFromDateTimeMap(dateTimes map[string]*DateTime) *map[string]time.Time { + if dateTimes == nil { + return nil + } + times := TimesFromDateTimeMap(dateTimes) + return × +} diff --git a/seed/go-sdk/go-global-headers/option/request_option.go b/seed/go-sdk/go-global-headers/option/request_option.go new file mode 100644 index 000000000000..e8ccc7abb34e --- /dev/null +++ b/seed/go-sdk/go-global-headers/option/request_option.go @@ -0,0 +1,131 @@ +// Code generated by Fern. DO NOT EDIT. + +package option + +import ( + core "github.com/go-global-headers/fern/core" + http "net/http" + url "net/url" +) + +// RequestOption adapts the behavior of an individual request. +type RequestOption = core.RequestOption + +// WithBaseURL sets the base URL, overriding the default +// environment, if any. +func WithBaseURL(baseURL string) *core.BaseURLOption { + return &core.BaseURLOption{ + BaseURL: baseURL, + } +} + +// WithHTTPClient uses the given HTTPClient to issue the request. +func WithHTTPClient(httpClient core.HTTPClient) *core.HTTPClientOption { + return &core.HTTPClientOption{ + HTTPClient: httpClient, + } +} + +// WithHTTPHeader adds the given http.Header to the request. +func WithHTTPHeader(httpHeader http.Header) *core.HTTPHeaderOption { + return &core.HTTPHeaderOption{ + // Clone the headers so they can't be modified after the option call. + HTTPHeader: httpHeader.Clone(), + } +} + +// WithBodyProperties adds the given body properties to the request. +func WithBodyProperties(bodyProperties map[string]interface{}) *core.BodyPropertiesOption { + copiedBodyProperties := make(map[string]interface{}, len(bodyProperties)) + for key, value := range bodyProperties { + copiedBodyProperties[key] = value + } + return &core.BodyPropertiesOption{ + BodyProperties: copiedBodyProperties, + } +} + +// WithQueryParameters adds the given query parameters to the request. +func WithQueryParameters(queryParameters url.Values) *core.QueryParametersOption { + copiedQueryParameters := make(url.Values, len(queryParameters)) + for key, values := range queryParameters { + copiedQueryParameters[key] = values + } + return &core.QueryParametersOption{ + QueryParameters: copiedQueryParameters, + } +} + +// WithMaxAttempts configures the maximum number of retry attempts. +func WithMaxAttempts(attempts uint) *core.MaxAttemptsOption { + return &core.MaxAttemptsOption{ + MaxAttempts: attempts, + } +} + +// WithMaxStreamBufSize configures the maximum buffer size for streaming responses. +// This controls the maximum size of a single message (in bytes) that the stream +// can process. By default, this is set to 1MB. +func WithMaxStreamBufSize(size int) *core.MaxBufSizeOption { + return &core.MaxBufSizeOption{ + MaxBufSize: size, + } +} + +// WithMaxStreamReconnectAttempts caps the number of transparent mid-stream +// reconnect attempts on streaming endpoints that support resumption. The +// reconnect loop honors Last-Event-ID and any server-sent `retry:` directives. +// Has no effect on endpoints that don't support resumption. +func WithMaxStreamReconnectAttempts(attempts uint) *core.MaxStreamReconnectAttemptsOption { + return &core.MaxStreamReconnectAttemptsOption{ + MaxStreamReconnectAttempts: attempts, + } +} + +// WithoutStreamReconnection disables transparent mid-stream reconnection on +// resumable SSE endpoints. Has no effect on non-resumable endpoints. +func WithoutStreamReconnection() *core.WithoutStreamReconnectionOption { + return &core.WithoutStreamReconnectionOption{} +} + +// WithoutRetries disables HTTP-level retry attempts for the request. Use this +// instead of WithMaxAttempts(0), which falls through to the default of 2 +// attempts. +func WithoutRetries() *core.WithoutRetriesOption { + return &core.WithoutRetriesOption{} +} + +// WithAPIKey sets the 'Authorization: Bearer ' request header. +func WithAPIKey(apiKey string) *core.APIKeyOption { + return &core.APIKeyOption{ + APIKey: apiKey, + } +} + +// WithAPIKeyFunc sets a function that returns the 'Authorization: Bearer' token at request time. +func WithAPIKeyFunc(fn func() (string, error)) *core.APIKeyFuncOption { + return &core.APIKeyFuncOption{ + APIKeyFunc: fn, + } +} + +// WithClientID sets the clientID request header. +func WithClientID(clientID string) *core.ClientIDOption { + return &core.ClientIDOption{ + ClientID: clientID, + } +} + +// WithVersion sets the version request header. +func WithVersion(version string) *core.VersionOption { + return &core.VersionOption{ + Version: version, + } +} + +// WithTenant sets the tenant request header. +func WithTenant(tenant *string) *core.TenantOption { + return &core.TenantOption{ + Tenant: tenant, + } +} diff --git a/seed/go-sdk/go-global-headers/pointer.go b/seed/go-sdk/go-global-headers/pointer.go new file mode 100644 index 000000000000..7b02d81fca85 --- /dev/null +++ b/seed/go-sdk/go-global-headers/pointer.go @@ -0,0 +1,137 @@ +package goglobalheaders + +import ( + "time" + + "github.com/google/uuid" +) + +// Bool returns a pointer to the given bool value. +func Bool(b bool) *bool { + return &b +} + +// Byte returns a pointer to the given byte value. +func Byte(b byte) *byte { + return &b +} + +// Bytes returns a pointer to the given []byte value. +func Bytes(b []byte) *[]byte { + return &b +} + +// Complex64 returns a pointer to the given complex64 value. +func Complex64(c complex64) *complex64 { + return &c +} + +// Complex128 returns a pointer to the given complex128 value. +func Complex128(c complex128) *complex128 { + return &c +} + +// Float32 returns a pointer to the given float32 value. +func Float32(f float32) *float32 { + return &f +} + +// Float64 returns a pointer to the given float64 value. +func Float64(f float64) *float64 { + return &f +} + +// Int returns a pointer to the given int value. +func Int(i int) *int { + return &i +} + +// Int8 returns a pointer to the given int8 value. +func Int8(i int8) *int8 { + return &i +} + +// Int16 returns a pointer to the given int16 value. +func Int16(i int16) *int16 { + return &i +} + +// Int32 returns a pointer to the given int32 value. +func Int32(i int32) *int32 { + return &i +} + +// Int64 returns a pointer to the given int64 value. +func Int64(i int64) *int64 { + return &i +} + +// Rune returns a pointer to the given rune value. +func Rune(r rune) *rune { + return &r +} + +// String returns a pointer to the given string value. +func String(s string) *string { + return &s +} + +// Uint returns a pointer to the given uint value. +func Uint(u uint) *uint { + return &u +} + +// Uint8 returns a pointer to the given uint8 value. +func Uint8(u uint8) *uint8 { + return &u +} + +// Uint16 returns a pointer to the given uint16 value. +func Uint16(u uint16) *uint16 { + return &u +} + +// Uint32 returns a pointer to the given uint32 value. +func Uint32(u uint32) *uint32 { + return &u +} + +// Uint64 returns a pointer to the given uint64 value. +func Uint64(u uint64) *uint64 { + return &u +} + +// Uintptr returns a pointer to the given uintptr value. +func Uintptr(u uintptr) *uintptr { + return &u +} + +// UUID returns a pointer to the given uuid.UUID value. +func UUID(u uuid.UUID) *uuid.UUID { + return &u +} + +// Time returns a pointer to the given time.Time value. +func Time(t time.Time) *time.Time { + return &t +} + +// MustParseDate attempts to parse the given string as a +// date time.Time, and panics upon failure. +func MustParseDate(date string) time.Time { + t, err := time.Parse("2006-01-02", date) + if err != nil { + panic(err) + } + return t +} + +// MustParseDateTime attempts to parse the given string as a +// datetime time.Time, and panics upon failure. +func MustParseDateTime(datetime string) time.Time { + t, err := time.Parse(time.RFC3339, datetime) + if err != nil { + panic(err) + } + return t +} diff --git a/seed/go-sdk/go-global-headers/pointer_test.go b/seed/go-sdk/go-global-headers/pointer_test.go new file mode 100644 index 000000000000..331decc06322 --- /dev/null +++ b/seed/go-sdk/go-global-headers/pointer_test.go @@ -0,0 +1,211 @@ +package goglobalheaders + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestBool(t *testing.T) { + value := true + ptr := Bool(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestByte(t *testing.T) { + value := byte(42) + ptr := Byte(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestComplex64(t *testing.T) { + value := complex64(1 + 2i) + ptr := Complex64(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestComplex128(t *testing.T) { + value := complex128(1 + 2i) + ptr := Complex128(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestFloat32(t *testing.T) { + value := float32(3.14) + ptr := Float32(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestFloat64(t *testing.T) { + value := 3.14159 + ptr := Float64(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestInt(t *testing.T) { + value := 42 + ptr := Int(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestInt8(t *testing.T) { + value := int8(42) + ptr := Int8(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestInt16(t *testing.T) { + value := int16(42) + ptr := Int16(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestInt32(t *testing.T) { + value := int32(42) + ptr := Int32(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestInt64(t *testing.T) { + value := int64(42) + ptr := Int64(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestRune(t *testing.T) { + value := 'A' + ptr := Rune(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestString(t *testing.T) { + value := "hello" + ptr := String(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUint(t *testing.T) { + value := uint(42) + ptr := Uint(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUint8(t *testing.T) { + value := uint8(42) + ptr := Uint8(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUint16(t *testing.T) { + value := uint16(42) + ptr := Uint16(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUint32(t *testing.T) { + value := uint32(42) + ptr := Uint32(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUint64(t *testing.T) { + value := uint64(42) + ptr := Uint64(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUintptr(t *testing.T) { + value := uintptr(42) + ptr := Uintptr(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestUUID(t *testing.T) { + value := uuid.New() + ptr := UUID(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestTime(t *testing.T) { + value := time.Now() + ptr := Time(value) + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestMustParseDate(t *testing.T) { + t.Run("valid date", func(t *testing.T) { + result := MustParseDate("2024-01-15") + expected, _ := time.Parse("2006-01-02", "2024-01-15") + assert.Equal(t, expected, result) + }) + + t.Run("invalid date panics", func(t *testing.T) { + assert.Panics(t, func() { + MustParseDate("invalid-date") + }) + }) +} + +func TestMustParseDateTime(t *testing.T) { + t.Run("valid datetime", func(t *testing.T) { + result := MustParseDateTime("2024-01-15T10:30:00Z") + expected, _ := time.Parse(time.RFC3339, "2024-01-15T10:30:00Z") + assert.Equal(t, expected, result) + }) + + t.Run("invalid datetime panics", func(t *testing.T) { + assert.Panics(t, func() { + MustParseDateTime("invalid-datetime") + }) + }) +} + +func TestPointerHelpersWithZeroValues(t *testing.T) { + t.Run("zero bool", func(t *testing.T) { + ptr := Bool(false) + assert.NotNil(t, ptr) + assert.Equal(t, false, *ptr) + }) + + t.Run("zero int", func(t *testing.T) { + ptr := Int(0) + assert.NotNil(t, ptr) + assert.Equal(t, 0, *ptr) + }) + + t.Run("empty string", func(t *testing.T) { + ptr := String("") + assert.NotNil(t, ptr) + assert.Equal(t, "", *ptr) + }) + + t.Run("zero time", func(t *testing.T) { + zeroTime := time.Time{} + ptr := Time(zeroTime) + assert.NotNil(t, ptr) + assert.Equal(t, zeroTime, *ptr) + }) +} diff --git a/seed/go-sdk/go-global-headers/reference.md b/seed/go-sdk/go-global-headers/reference.md new file mode 100644 index 000000000000..34dfbb4ffbe3 --- /dev/null +++ b/seed/go-sdk/go-global-headers/reference.md @@ -0,0 +1,43 @@ +# Reference +## Service +
client.Service.Get() -> string +
+
+ +#### 📝 Description + +
+
+ +
+
+ +GET request that sends the global headers +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +client.Service.Get( + context.TODO(), +) +``` +
+
+
+
+ + +
+
+
+ diff --git a/seed/go-sdk/go-global-headers/service/client.go b/seed/go-sdk/go-global-headers/service/client.go new file mode 100644 index 000000000000..bed8072855c0 --- /dev/null +++ b/seed/go-sdk/go-global-headers/service/client.go @@ -0,0 +1,59 @@ +// Code generated by Fern. DO NOT EDIT. + +package service + +import ( + context "context" + os "os" + + core "github.com/go-global-headers/fern/core" + internal "github.com/go-global-headers/fern/internal" + option "github.com/go-global-headers/fern/option" +) + +type Client struct { + WithRawResponse *RawClient + + options *core.RequestOptions + baseURL string + caller *internal.Caller +} + +func NewClient(options *core.RequestOptions) *Client { + if options.APIKey == "" { + options.APIKey = os.Getenv("MY_API_KEY") + } + return &Client{ + WithRawResponse: NewRawClient(options), + options: options, + baseURL: options.BaseURL, + caller: internal.NewCaller( + &internal.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + }, + ), + } +} + +// GET request that sends the global headers +// +// Example: +// +// client.Service.Get( +// context.TODO(), +// ) +func (c *Client) Get( + ctx context.Context, + opts ...option.RequestOption, +) (string, error) { + response, err := c.WithRawResponse.Get( + ctx, + opts..., + ) + if err != nil { + return "", err + } + return response.Body, nil +} diff --git a/seed/go-sdk/go-global-headers/service/raw_client.go b/seed/go-sdk/go-global-headers/service/raw_client.go new file mode 100644 index 000000000000..cde569f301ea --- /dev/null +++ b/seed/go-sdk/go-global-headers/service/raw_client.go @@ -0,0 +1,72 @@ +// Code generated by Fern. DO NOT EDIT. + +package service + +import ( + context "context" + http "net/http" + + core "github.com/go-global-headers/fern/core" + internal "github.com/go-global-headers/fern/internal" + option "github.com/go-global-headers/fern/option" +) + +type RawClient struct { + baseURL string + caller *internal.Caller + options *core.RequestOptions +} + +func NewRawClient(options *core.RequestOptions) *RawClient { + return &RawClient{ + options: options, + baseURL: options.BaseURL, + caller: internal.NewCaller( + &internal.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + }, + ), + } +} + +func (r *RawClient) Get( + ctx context.Context, + opts ...option.RequestOption, +) (*core.Response[string], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "", + ) + endpointURL := baseURL + "/global-headers" + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + var response string + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + Headers: headers, + MaxAttempts: options.MaxAttempts, + DisableRetries: options.DisableRetries, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Response: &response, + }, + ) + if err != nil { + return nil, err + } + return &core.Response[string]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} diff --git a/seed/go-sdk/go-global-headers/snippet.json b/seed/go-sdk/go-global-headers/snippet.json new file mode 100644 index 000000000000..ad7454539326 --- /dev/null +++ b/seed/go-sdk/go-global-headers/snippet.json @@ -0,0 +1,15 @@ +{ + "endpoints": [ + { + "id": { + "path": "/global-headers", + "method": "GET", + "identifier_override": "endpoint_service.get" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfernclient \"github.com/go-global-headers/fern/client\"\n\toption \"github.com/go-global-headers/fern/option\"\n)\n\nclient := fernclient.NewClient(\n\toption.WithAPIKey(\n\t\t\"\u003cYOUR_AUTH_TOKEN\u003e\",\n\t),\n)\nresponse, err := client.Service.Get(\n\tcontext.TODO(),\n)\n" + } + } + ] +} \ No newline at end of file diff --git a/test-definitions/fern/apis/go-global-headers/definition/api.yml b/test-definitions/fern/apis/go-global-headers/definition/api.yml new file mode 100644 index 000000000000..583a4aa4d66e --- /dev/null +++ b/test-definitions/fern/apis/go-global-headers/definition/api.yml @@ -0,0 +1,18 @@ +name: go-global-headers +auth: Bearer +auth-schemes: + Bearer: + scheme: bearer + token: + name: apiKey + env: MY_API_KEY +headers: + X-API-Client-Id: + name: clientId + type: string + X-API-Version: + name: version + type: string + X-API-Tenant: + name: tenant + type: optional diff --git a/test-definitions/fern/apis/go-global-headers/definition/service.yml b/test-definitions/fern/apis/go-global-headers/definition/service.yml new file mode 100644 index 000000000000..702663ff3794 --- /dev/null +++ b/test-definitions/fern/apis/go-global-headers/definition/service.yml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json + +service: + auth: false + base-path: "" + endpoints: + get: + auth: true + docs: GET request that sends the global headers + path: /global-headers + method: GET + response: string diff --git a/test-definitions/fern/apis/go-global-headers/generators.yml b/test-definitions/fern/apis/go-global-headers/generators.yml new file mode 100644 index 000000000000..ef38e3c1cf00 --- /dev/null +++ b/test-definitions/fern/apis/go-global-headers/generators.yml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +groups: + go-sdk: + generators: + - name: fernapi/fern-go-sdk + version: latest + ir-version: v61 + github: + token: ${GITHUB_TOKEN} + mode: push + uri: fern-api/go-sdk-tests + branch: go-global-headers