From f18b07dbf3a5b4a7e55c65dffb9b1a1e878db652 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Mon, 8 Sep 2025 16:16:18 -0400 Subject: [PATCH 01/18] initial commit --- generators/go-v2/ast/src/ast/core/Writer.ts | 2 +- .../src/DynamicSnippetsGenerator.ts | 15 +- .../src/EndpointSnippetGenerator.ts | 415 +++++++++++++++++- .../DynamicSnippetsGeneratorContext.ts | 27 ++ generators/go-v2/sdk/src/SdkGeneratorCli.ts | 32 ++ .../reference/EndpointSnippetsGenerator.ts | 8 + 6 files changed, 490 insertions(+), 9 deletions(-) diff --git a/generators/go-v2/ast/src/ast/core/Writer.ts b/generators/go-v2/ast/src/ast/core/Writer.ts index 4ca2b054701..64e0bacbaa7 100644 --- a/generators/go-v2/ast/src/ast/core/Writer.ts +++ b/generators/go-v2/ast/src/ast/core/Writer.ts @@ -94,6 +94,6 @@ export class Writer extends AbstractWriter { if (split[0] == null) { return s; } - return split[0].replace(INVALID_GO_IDENTIFIER_TOKEN, ""); + return split.map((part) => part.replace(INVALID_GO_IDENTIFIER_TOKEN, "")).join(""); } } diff --git a/generators/go-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts b/generators/go-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts index fa5ba27e8cb..22b0985546e 100644 --- a/generators/go-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts +++ b/generators/go-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts @@ -1,7 +1,8 @@ import { AbstractDynamicSnippetsGenerator, AbstractFormatter, - FernGeneratorExec + FernGeneratorExec, + Options } from "@fern-api/browser-compatible-base-generator"; import { FernIr } from "@fern-api/dynamic-ir-sdk"; import { DynamicSnippetsGeneratorContext } from "./context/DynamicSnippetsGeneratorContext"; @@ -27,13 +28,17 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator< } public async generate( - request: FernIr.dynamic.EndpointSnippetRequest + request: FernIr.dynamic.EndpointSnippetRequest, + options?: Options ): Promise { - return super.generate(request); + return super.generate(request, options); } - public generateSync(request: FernIr.dynamic.EndpointSnippetRequest): FernIr.dynamic.EndpointSnippetResponse { - return super.generateSync(request); + public generateSync( + request: FernIr.dynamic.EndpointSnippetRequest, + options?: Options + ): FernIr.dynamic.EndpointSnippetResponse { + return super.generateSync(request, options); } protected createSnippetGenerator(context: DynamicSnippetsGeneratorContext): EndpointSnippetGenerator { diff --git a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index 4cd87a1a7fa..865bfe62ef7 100644 --- a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -1,4 +1,4 @@ -import { AbstractFormatter, Scope, Severity } from "@fern-api/browser-compatible-base-generator"; +import { AbstractFormatter, Options, Scope, Severity } from "@fern-api/browser-compatible-base-generator"; import { assertNever } from "@fern-api/core-utils"; import { FernIr } from "@fern-api/dynamic-ir-sdk"; import { go } from "@fern-api/go-ast"; @@ -11,6 +11,8 @@ const SNIPPET_IMPORT_PATH = "fern"; const SNIPPET_FUNC_NAME = "do"; const CLIENT_VAR_NAME = "client"; const TypeInst = go.TypeInstantiation; +const WIREMOCK_BASE_URL = "wireMockBaseURL"; +const WIREMOCK_CLIENT_VAR_NAME = "wiremockClient"; export class EndpointSnippetGenerator { private context: DynamicSnippetsGeneratorContext; @@ -23,11 +25,18 @@ export class EndpointSnippetGenerator { public async generateSnippet({ endpoint, - request + request, + options }: { endpoint: FernIr.dynamic.Endpoint; request: FernIr.dynamic.EndpointSnippetRequest; + options?: Options; }): Promise { + const outputWiremockTests = + (options?.config as { outputWiremockTests?: boolean })?.outputWiremockTests ?? false; + if (outputWiremockTests) { + return this.generateWiremockTest({ endpoint, snippet: request }); + } const code = this.buildCodeBlock({ endpoint, snippet: request }); return await code.toStringAsync({ packageName: SNIPPET_PACKAGE_NAME, @@ -40,11 +49,18 @@ export class EndpointSnippetGenerator { public generateSnippetSync({ endpoint, - request + request, + options }: { endpoint: FernIr.dynamic.Endpoint; request: FernIr.dynamic.EndpointSnippetRequest; + options?: Options; }): string { + const outputWiremockTests = + (options?.config as { outputWiremockTests?: boolean })?.outputWiremockTests ?? false; + if (outputWiremockTests) { + return this.generateWiremockTest({ endpoint, snippet: request }); + } const code = this.buildCodeBlock({ endpoint, snippet: request }); return code.toString({ packageName: SNIPPET_PACKAGE_NAME, @@ -55,6 +71,23 @@ export class EndpointSnippetGenerator { }); } + private generateWiremockTest({ + endpoint, + snippet + }: { + endpoint: FernIr.dynamic.Endpoint; + snippet: FernIr.dynamic.EndpointSnippetRequest; + }): string { + const code = this.buildWiremockTestCodeBlock({ endpoint, snippet }); + return code.toString({ + packageName: "wiremock_test", + importPath: SNIPPET_IMPORT_PATH, + rootImportPath: this.context.rootImportPath, + customConfig: this.context.customConfig ?? {}, + formatter: this.formatter + }); + } + private buildCodeBlock({ endpoint, snippet @@ -79,6 +112,40 @@ export class EndpointSnippetGenerator { }); } + private buildWiremockTestCodeBlock({ + endpoint, + snippet + }: { + endpoint: FernIr.dynamic.Endpoint; + snippet: FernIr.dynamic.EndpointSnippetRequest; + }): go.AstNode { + return go.codeblock((writer) => { + writer.writeNode( + go.func({ + name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "WithWireMock", // TODO: figure out how to deduplicate these across multiple examples + parameters: [ + go.parameter({ + name: "t", + type: go.Type.pointer(go.Type.reference(this.context.getTestingTypeReference())) + }) + ], + return_: [], + body: go.codeblock((writer) => { + for (const node of this.buildWiremockTestSetup({ endpoint })) { + writer.writeNode(node); + writer.writeLine(); + } + writer.writeLine(); + writer.writeNode(this.constructWiremockTestClient({ endpoint, snippet })); + writer.writeLine(); + writer.writeNode(this.callClientMethodAndAssert({ endpoint, snippet })); + }) + }) + ); + writer.writeNewLineIfLastLineNot(); + }); + } + private constructClient({ endpoint, snippet @@ -92,6 +159,19 @@ export class EndpointSnippetGenerator { }); } + private constructWiremockTestClient({ + endpoint, + snippet + }: { + endpoint: FernIr.dynamic.Endpoint; + snippet: FernIr.dynamic.EndpointSnippetRequest; + }): go.CodeBlock { + return go.codeblock((writer) => { + writer.write(`${CLIENT_VAR_NAME} := `); + writer.writeNode(this.getRootClientFuncInvocation(this.getWiremockTestConstructorArgs())); + }); + } + private callMethod({ endpoint, snippet @@ -139,6 +219,22 @@ export class EndpointSnippetGenerator { return args; } + private getWiremockTestConstructorArgs(): go.AstNode[] { + return [ + go.codeblock((writer) => { + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "WithBaseURL", + importPath: this.context.getOptionImportPath() + }), + arguments_: [go.codeblock(`"http://" + ${WIREMOCK_BASE_URL}`)] + }) + ); + }) + ]; + } + private getConstructorAuthArg({ auth, values @@ -682,4 +778,317 @@ export class EndpointSnippetGenerator { arguments_ }); } + + private buildWiremockTestSetup({ endpoint }: { endpoint: FernIr.dynamic.Endpoint }): go.AstNode[] { + const WIREMOCK_CONTAINER_NAME = "container"; + const ENDPOINT_STUB_NAME = "stub"; + return [ + // Initialize context + go.codeblock((writer) => { + writer.write("ctx := "); + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "Background", + importPath: "context" + }), + arguments_: [], + multiline: false + }) + ); + }), + + // Start WireMock container + go.codeblock((writer) => { + writer.write(`${WIREMOCK_CONTAINER_NAME}, containerErr := `); + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "RunContainerAndStopOnCleanup", + importPath: "github.com/wiremock/wiremock-testcontainers-go" + }), + arguments_: [ + go.codeblock("ctx"), + go.codeblock("t"), + go.invokeFunc({ + func: go.typeReference({ + name: "WithImage", + importPath: "github.com/wiremock/wiremock-testcontainers-go" + }), + arguments_: [go.TypeInstantiation.string("docker.io/wiremock/wiremock:3.9.1")], + multiline: false + }) + ], + multiline: true + }) + ); + }), + + // Check for container error + go.codeblock((writer) => { + writer.write("if containerErr != nil {"); + writer.writeLine(); + writer.write(" t.Fatal(containerErr)"); + writer.writeLine(); + writer.write("}"); + }), + + // Get WireMock URL from container + go.codeblock((writer) => { + writer.write(`${WIREMOCK_BASE_URL}, endpointErr := `); + writer.writeNode( + go.invokeMethod({ + on: go.codeblock(WIREMOCK_CONTAINER_NAME), + method: "Endpoint", + arguments_: [go.codeblock("ctx"), go.TypeInstantiation.string("")], + multiline: false + }) + ); + }), + + go.invokeFunc({ + func: go.typeReference({ + name: "NoError", + importPath: "github.com/stretchr/testify/require" + }), + arguments_: [ + go.codeblock("t"), + go.codeblock("endpointErr"), + go.TypeInstantiation.string("Failed to get WireMock container endpoint") + ], + multiline: false + }), + + // Get WireMock client from container + go.codeblock((writer) => { + writer.write("wiremockClient := "); + writer.writeNode( + go.selector({ + on: go.codeblock("container"), + selector: go.codeblock("Client") + }) + ); + }), + + go.codeblock((writer) => { + writer.write("defer "); + writer.writeNode( + go.invokeMethod({ + on: go.codeblock("wiremockClient"), + method: "Reset", + arguments_: [], + multiline: false + }) + ); + }), + + // Create a mock response for the endpoint + go.codeblock((writer) => { + writer.write(`${ENDPOINT_STUB_NAME} := `); + writer.writeNode( + DynamicSnippetsGeneratorContext.chainMethods( + go.invokeFunc({ + func: go.typeReference({ + name: endpoint.location.method.toLowerCase().replace(/^./, (c) => c.toUpperCase()), + importPath: "github.com/wiremock/go-wiremock" + }), + arguments_: [ + go.invokeFunc({ + func: go.typeReference({ + name: "URLPathTemplate", + importPath: "github.com/wiremock/go-wiremock" + }), + arguments_: [go.TypeInstantiation.string(endpoint.location.path)], + multiline: false + }) + ], + multiline: false + }), + ...(endpoint.request.type === "inlined" && endpoint.request.queryParameters + ? endpoint.request.queryParameters.map((queryParameter: FernIr.dynamic.NamedParameter) => ({ + method: "WithQueryParam", + arguments_: [ + go.TypeInstantiation.string(queryParameter.name.wireValue), + go.invokeFunc({ + func: go.typeReference({ + name: "Matching", + importPath: "github.com/wiremock/go-wiremock" + }), + arguments_: [go.TypeInstantiation.string(".+")], + multiline: false + }) + ] + })) + : []), + ...(endpoint.request.pathParameters && endpoint.request.pathParameters.length > 0 + ? endpoint.request.pathParameters.map((pathParameter: FernIr.dynamic.NamedParameter) => ({ + method: "WithPathParam", + arguments_: [ + go.TypeInstantiation.string(pathParameter.name.wireValue), + go.invokeFunc({ + func: go.typeReference({ + name: "Matching", + importPath: "github.com/wiremock/go-wiremock" + }), + arguments_: [go.TypeInstantiation.string(".+")], + multiline: false + }) + ] + })) + : []), + { + method: "WillReturnResponse", + arguments_: [ + DynamicSnippetsGeneratorContext.chainMethods( + go.invokeFunc({ + func: go.typeReference({ + name: "NewResponse", + importPath: "github.com/wiremock/go-wiremock" + }), + arguments_: [], + multiline: false + }), + { + method: "WithJSONBody", + arguments_: [go.codeblock("map[string]interface{}{}")] + }, + { + method: "WithStatus", + arguments_: [ + go.typeReference({ + name: "StatusOK", + importPath: "net/http" + }) + ], + multiline: false + } + ) + ] + } + ) + ); + }), + + // Register the stub with WireMock + go.codeblock((writer) => { + writer.write("err := "); + writer.writeNode( + go.invokeMethod({ + on: go.codeblock("wiremockClient"), + method: "StubFor", + arguments_: [go.codeblock(ENDPOINT_STUB_NAME)], + multiline: false + }) + ); + }), + + go.invokeFunc({ + func: go.typeReference({ + name: "NoError", + importPath: "github.com/stretchr/testify/require" + }), + arguments_: [ + go.codeblock("t"), + go.codeblock("err"), + go.TypeInstantiation.string("Failed to create WireMock stub") + ], + multiline: false + }) + ]; + } + + private callClientMethodAndAssert({ + endpoint, + snippet + }: { + endpoint: FernIr.dynamic.Endpoint; + snippet: FernIr.dynamic.EndpointSnippetRequest; + }): go.CodeBlock { + return go.codeblock((writer) => { + // Call the method and capture response and error + writer.write("_, invocationErr := "); + writer.writeNode( + go.invokeMethod({ + on: go.codeblock(CLIENT_VAR_NAME), + method: this.getMethod({ endpoint }), + arguments_: [ + this.context.getContextTodoFunctionInvocation(), + ...this.getMethodArgs({ endpoint, snippet }) + ] + }) + ); + writer.writeLine(); + writer.writeLine(); + + // Verify WireMock request was matched + writer.write("ok, countErr := "); + writer.writeNode( + go.invokeMethod({ + on: go.codeblock("wiremockClient"), + method: "Verify", + arguments_: [ + go.invokeMethod({ + on: go.codeblock("stub"), + method: "Request", + arguments_: [], + multiline: false + }), + go.codeblock("1") + ], + multiline: false + }) + ); + writer.writeLine(); + + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "NoError", + importPath: "github.com/stretchr/testify/require" + }), + arguments_: [ + go.codeblock("t"), + go.codeblock("countErr"), + go.TypeInstantiation.string("Failed to verify WireMock request was matched") + ], + multiline: false + }) + ); + writer.writeLine(); + + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "True", + importPath: "github.com/stretchr/testify/require" + }), + arguments_: [ + go.codeblock("t"), + go.codeblock("ok"), + go.TypeInstantiation.string("WireMock request was not matched") + ], + multiline: false + }) + ); + writer.writeLine(); + + // Verify the call succeeded (may not assert this at all and only assert the WireMock request was matched) + // Since we don't necessarily have valid response bodies in our WireMock stubs (so type casting will fail) + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "NoError", + importPath: "github.com/stretchr/testify/require" + }), + arguments_: [ + go.codeblock("t"), + go.codeblock("invocationErr"), + go.TypeInstantiation.string(`${this.getMethod({ endpoint })} call should succeed with WireMock`) + ], + multiline: false + }) + ); + writer.writeLine(); + }); + } } diff --git a/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts b/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts index 30524959578..ace3e15c9b7 100644 --- a/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts +++ b/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts @@ -77,6 +77,13 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene }); } + public getTestingTypeReference(): go.TypeReference { + return go.typeReference({ + name: "T", + importPath: "testing" + }); + } + public getNewStringsReaderFunctionInvocation(s: string): go.FuncInvocation { return go.invokeFunc({ func: go.typeReference({ @@ -137,4 +144,24 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene importPath: this.rootImportPath }); } + + public static chainMethods( + baseFunc: go.FuncInvocation, + ...methods: Omit[] + ): go.MethodInvocation { + if (methods.length === 0) { + throw new Error("Must have methods to chain"); + } + + let current: go.AstNode = baseFunc; + for (const method of methods) { + current = go.invokeMethod({ + on: current, + method: method.method, + arguments_: method.arguments_, + multiline: method.multiline + }); + } + return current as go.MethodInvocation; + } } diff --git a/generators/go-v2/sdk/src/SdkGeneratorCli.ts b/generators/go-v2/sdk/src/SdkGeneratorCli.ts index 8d969455bf6..c57cb06dd26 100644 --- a/generators/go-v2/sdk/src/SdkGeneratorCli.ts +++ b/generators/go-v2/sdk/src/SdkGeneratorCli.ts @@ -59,6 +59,11 @@ export class SdkGeneratorCLI extends AbstractGoGeneratorCli { + const dynamicIr = context.ir.dynamic; + if (dynamicIr == null) { + throw new Error("Cannot generate wiremock tests without dynamic IR"); + } + + const dynamicSnippetsGenerator = new DynamicSnippetsGenerator({ + ir: convertIr(dynamicIr), + config: context.config + }); + + for (const endpoint of Object.values(dynamicIr.endpoints)) { + for (const endpointExample of endpoint.examples ?? []) { + const wiremockTestFilename = + endpoint.declaration.name.snakeCase.safeName + "_" + endpointExample.id + "_test.go"; + const wiremockTestContent = dynamicSnippetsGenerator.generateSync( + convertDynamicEndpointSnippetRequest(endpointExample), + { config: { outputWiremockTests: true } } + ).snippet; + + context.project.addRawFiles( + new File(wiremockTestFilename, RelativeFilePath.of("./tests"), wiremockTestContent) + ); + } + } + } + private async generateReadme({ context, endpointSnippets diff --git a/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts b/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts index 5704a3a6987..bcee4351b37 100644 --- a/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts +++ b/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts @@ -21,12 +21,18 @@ export class EndpointSnippetsGenerator { private readonly context: SdkGeneratorContext; private readonly snippetsCache: Map = new Map(); + private snippetsCacheInitialized: boolean = false; constructor({ context }: { context: SdkGeneratorContext }) { this.context = context; + this.snippetsCacheInitialized = false; } public async populateSnippetsCache(): Promise { + if (this.snippetsCacheInitialized) { + return; + } + const endpointSnippetsById = new Map(); const dynamicIr = this.context.ir.dynamic; @@ -96,6 +102,8 @@ export class EndpointSnippetsGenerator { endpointSnippetsById.forEach((value, key) => { this.snippetsCache.set(key, value); }); + + this.snippetsCacheInitialized = true; } public getSnippetsForEndpoint(endpointId: string): EndpointSnippets | undefined { From 50ffa002184250df10cc0407e277d2ab2450eb48 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Mon, 8 Sep 2025 17:52:32 -0400 Subject: [PATCH 02/18] Use only first endpoint example so as to avoid name and file collisions --- generators/go-v2/sdk/src/SdkGeneratorCli.ts | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/generators/go-v2/sdk/src/SdkGeneratorCli.ts b/generators/go-v2/sdk/src/SdkGeneratorCli.ts index c57cb06dd26..2724e02a474 100644 --- a/generators/go-v2/sdk/src/SdkGeneratorCli.ts +++ b/generators/go-v2/sdk/src/SdkGeneratorCli.ts @@ -61,6 +61,7 @@ export class SdkGeneratorCLI extends AbstractGoGeneratorCli Date: Wed, 10 Sep 2025 11:12:37 -0400 Subject: [PATCH 03/18] add support for as is files --- generators/go-v2/base/src/AsIs.ts | 3 +- .../go-v2/base/src/asIs/test/test_main.go_ | 56 +++++++++++++++++++ .../src/context/AbstractGoGeneratorContext.ts | 2 + .../go-v2/base/src/project/GoProject.ts | 11 ++++ generators/go-v2/sdk/build.mjs | 29 ++++++++++ generators/go-v2/sdk/package.json | 2 +- generators/go-v2/sdk/src/SdkGeneratorCli.ts | 2 +- .../go-v2/sdk/src/SdkGeneratorContext.ts | 6 +- 8 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 generators/go-v2/base/src/asIs/test/test_main.go_ create mode 100644 generators/go-v2/sdk/build.mjs diff --git a/generators/go-v2/base/src/AsIs.ts b/generators/go-v2/base/src/AsIs.ts index 97a20674220..2896bdc8aa3 100644 --- a/generators/go-v2/base/src/AsIs.ts +++ b/generators/go-v2/base/src/AsIs.ts @@ -3,5 +3,6 @@ export enum AsIsFiles { ExtraProperties = "internal/extra_properties.go_", ExtraPropertiesTest = "internal/extra_properties_test.go_", Stringer = "internal/stringer.go_", - Time = "internal/time.go_" + Time = "internal/time.go_", + TestMain = "test/test_main.go_" } diff --git a/generators/go-v2/base/src/asIs/test/test_main.go_ b/generators/go-v2/base/src/asIs/test/test_main.go_ new file mode 100644 index 00000000000..cbce96b595f --- /dev/null +++ b/generators/go-v2/base/src/asIs/test/test_main.go_ @@ -0,0 +1,56 @@ +package wiremock_test + +import ( + "context" + "fmt" + "os" + "testing" + + wiremocktestcontainersgo "github.com/wiremock/wiremock-testcontainers-go" +) + +// Global test fixtures +var ( + WireMockContainer *wiremocktestcontainersgo.WireMockContainer + WireMockBaseURL string + WireMockClient *wiremocktestcontainersgo.WireMockClient +) + +// TestMain sets up shared test fixtures for all tests in this package +func TestMain(m *testing.M) { + // Setup shared WireMock container + ctx := context.Background() + container, err := wiremocktestcontainersgo.RunContainerAndStopOnCleanup( + ctx, + &testing.T{}, // We need a testing.T for the cleanup function, but we'll handle cleanup manually + wiremocktestcontainersgo.WithImage("docker.io/wiremock/wiremock:3.9.1"), + ) + if err != nil { + fmt.Printf("Failed to start WireMock container: %v\n", err) + os.Exit(1) + } + + // Store global references + WireMockContainer = container + WireMockClient = container.Client + + // Get the base URL + baseURL, err := container.Endpoint(ctx, "") + if err != nil { + fmt.Printf("Failed to get WireMock container endpoint: %v\n", err) + os.Exit(1) + } + WireMockBaseURL = "http://" + baseURL + + // Run all tests + code := m.Run() + + // This step is most likely unnecessary + // Cleanup + //if WireMockContainer != nil { + // WireMockContainer.Terminate(ctx) + //} + + // Exit with the same code as the tests + os.Exit(code) +} diff --git a/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts b/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts index cb8df2b415e..2cf9bd6a730 100644 --- a/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts +++ b/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts @@ -667,4 +667,6 @@ export abstract class AbstractGoGeneratorContext< } public abstract getInternalAsIsFiles(): string[]; + + public abstract getInternalTestAsIsFiles(): string[]; } diff --git a/generators/go-v2/base/src/project/GoProject.ts b/generators/go-v2/base/src/project/GoProject.ts index 8a7d5c4fe8f..87d14c7a3d7 100644 --- a/generators/go-v2/base/src/project/GoProject.ts +++ b/generators/go-v2/base/src/project/GoProject.ts @@ -89,6 +89,17 @@ export class GoProject extends AbstractProject { + const sharedTestFiles = await Promise.all( + this.context.getInternalTestAsIsFiles().map(async (filename) => await this.createAsIsFile({ filename })) + ); + + return await this.createGoDirectory({ + absolutePathToDirectory: join(this.absolutePathToOutputDirectory), + files: sharedTestFiles + }); + } + private async createGoDirectory({ absolutePathToDirectory, files diff --git a/generators/go-v2/sdk/build.mjs b/generators/go-v2/sdk/build.mjs new file mode 100644 index 00000000000..492648aeba4 --- /dev/null +++ b/generators/go-v2/sdk/build.mjs @@ -0,0 +1,29 @@ +import { join, dirname } from "path"; +import { cp } from "fs/promises"; +import { fileURLToPath } from "url"; +import tsup from "tsup"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +main(); + +async function main() { + await tsup.build({ + entry: ["src/cli.ts"], + format: ["cjs"], + minify: false, + outDir: "dist", + sourcemap: true, + clean: true, + }); + const filesFoldersToCopy = [ + ["../base/src/asIs", "./dist/asIs"], + ]; + for (const [source, destination] of filesFoldersToCopy) { + await cp(join(__dirname, source), join(__dirname, destination), { + recursive: true, + force: true, + }); + } +} \ No newline at end of file diff --git a/generators/go-v2/sdk/package.json b/generators/go-v2/sdk/package.json index cda35f3e6c6..b430a20bfec 100644 --- a/generators/go-v2/sdk/package.json +++ b/generators/go-v2/sdk/package.json @@ -26,7 +26,7 @@ "compile": "tsc --build", "compile:debug": "tsc --build --sourceMap", "depcheck": "depcheck", - "dist:cli": "pnpm compile && tsup ./src/cli.ts --format cjs --sourcemap && cp -R ../base/src/asIs dist", + "dist:cli": "pnpm compile && tsup ./src/cli.ts --format cjs --sourcemap && node build.mjs", "dockerTagLatest": "pnpm dist:cli && docker build -f ./Dockerfile -t fernapi/fern-go-sdk:latest ../../..", "format": "prettier --write --ignore-unknown --ignore-path ../../../shared/.prettierignore \"**\"", "format:check": "prettier --check --ignore-unknown --ignore-path ../../../shared/.prettierignore \"**\"", diff --git a/generators/go-v2/sdk/src/SdkGeneratorCli.ts b/generators/go-v2/sdk/src/SdkGeneratorCli.ts index 2724e02a474..a14a0d63157 100644 --- a/generators/go-v2/sdk/src/SdkGeneratorCli.ts +++ b/generators/go-v2/sdk/src/SdkGeneratorCli.ts @@ -61,7 +61,7 @@ export class SdkGeneratorCLI extends AbstractGoGeneratorCli Date: Wed, 10 Sep 2025 11:41:57 -0400 Subject: [PATCH 04/18] include all dist outputs in go-v2 generator --- generators/go/sdk/Dockerfile | 8 ++++---- generators/go/sdk/Dockerfile.dockerignore | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/generators/go/sdk/Dockerfile b/generators/go/sdk/Dockerfile index 208124ada69..65ac4f5f648 100644 --- a/generators/go/sdk/Dockerfile +++ b/generators/go/sdk/Dockerfile @@ -4,7 +4,7 @@ FROM node:22.12-alpine3.20 AS node RUN apk --no-cache add git zip RUN git config --global user.name "fern" && git config --global user.email "hey@buildwithfern.com" -COPY generators/go-v2/sdk/dist/cli.cjs /dist/cli.cjs +COPY generators/go-v2/sdk/dist/ /dist/ # Stage 2: Final Go image FROM golang:1.23.8-alpine3.20 @@ -29,9 +29,9 @@ COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx -# Copy Node CLI from first stage and rename it /bin/go-v2 -COPY --from=node /dist/cli.cjs /bin/go-v2 -RUN chmod +x /bin/go-v2 +# Copy all Node CLI contents from first stage to /bin/ and rename cli.cjs to go-v2 +COPY --from=node /dist/ /bin/ +RUN mv /bin/cli.cjs /bin/go-v2 && chmod +x /bin/go-v2 RUN CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -buildvcs=false -o /fern-go-sdk ./cmd/fern-go-sdk diff --git a/generators/go/sdk/Dockerfile.dockerignore b/generators/go/sdk/Dockerfile.dockerignore index 15cc907a4f7..34eb242672c 100644 --- a/generators/go/sdk/Dockerfile.dockerignore +++ b/generators/go/sdk/Dockerfile.dockerignore @@ -1,6 +1,6 @@ * !generators/go-v2/sdk/features.yml -!generators/go-v2/sdk/dist/cli.cjs +!generators/go-v2/sdk/dist/ !generators/go/go.mod !generators/go/go.sum !generators/go/cmd From 2c572767acadf558c2fb4e8a999f143071f411ac Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Thu, 11 Sep 2025 11:42:03 -0400 Subject: [PATCH 05/18] mvp fixes --- generators/go-v2/base/src/AsIs.ts | 2 +- .../test/{test_main.go_ => main_test.go_} | 5 +- .../src/EndpointSnippetGenerator.ts | 352 +++++++++++------- .../go-v2/sdk/src/SdkGeneratorContext.ts | 2 +- 4 files changed, 217 insertions(+), 144 deletions(-) rename generators/go-v2/base/src/asIs/test/{test_main.go_ => main_test.go_} (92%) diff --git a/generators/go-v2/base/src/AsIs.ts b/generators/go-v2/base/src/AsIs.ts index 2896bdc8aa3..f724664210f 100644 --- a/generators/go-v2/base/src/AsIs.ts +++ b/generators/go-v2/base/src/AsIs.ts @@ -4,5 +4,5 @@ export enum AsIsFiles { ExtraPropertiesTest = "internal/extra_properties_test.go_", Stringer = "internal/stringer.go_", Time = "internal/time.go_", - TestMain = "test/test_main.go_" + MainTest = "test/main_test.go_" } diff --git a/generators/go-v2/base/src/asIs/test/test_main.go_ b/generators/go-v2/base/src/asIs/test/main_test.go_ similarity index 92% rename from generators/go-v2/base/src/asIs/test/test_main.go_ rename to generators/go-v2/base/src/asIs/test/main_test.go_ index cbce96b595f..611220895cb 100644 --- a/generators/go-v2/base/src/asIs/test/test_main.go_ +++ b/generators/go-v2/base/src/asIs/test/main_test.go_ @@ -1,4 +1,4 @@ -package wiremock_test +package wiremock import ( "context" @@ -6,6 +6,7 @@ import ( "os" "testing" + gowiremock "github.com/wiremock/go-wiremock" wiremocktestcontainersgo "github.com/wiremock/wiremock-testcontainers-go" ) @@ -13,7 +14,7 @@ import ( var ( WireMockContainer *wiremocktestcontainersgo.WireMockContainer WireMockBaseURL string - WireMockClient *wiremocktestcontainersgo.WireMockClient + WireMockClient *gowiremock.Client ) // TestMain sets up shared test fixtures for all tests in this package diff --git a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index 865bfe62ef7..b41e36fede5 100644 --- a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -11,8 +11,8 @@ const SNIPPET_IMPORT_PATH = "fern"; const SNIPPET_FUNC_NAME = "do"; const CLIENT_VAR_NAME = "client"; const TypeInst = go.TypeInstantiation; -const WIREMOCK_BASE_URL = "wireMockBaseURL"; -const WIREMOCK_CLIENT_VAR_NAME = "wiremockClient"; +const WIREMOCK_BASE_URL = "WireMockBaseURL"; +const WIREMOCK_CLIENT_VAR_NAME = "WireMockClient"; export class EndpointSnippetGenerator { private context: DynamicSnippetsGeneratorContext; @@ -80,7 +80,7 @@ export class EndpointSnippetGenerator { }): string { const code = this.buildWiremockTestCodeBlock({ endpoint, snippet }); return code.toString({ - packageName: "wiremock_test", + packageName: "wiremock", importPath: SNIPPET_IMPORT_PATH, rootImportPath: this.context.rootImportPath, customConfig: this.context.customConfig ?? {}, @@ -122,7 +122,7 @@ export class EndpointSnippetGenerator { return go.codeblock((writer) => { writer.writeNode( go.func({ - name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "WithWireMock", // TODO: figure out how to deduplicate these across multiple examples + name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "WithWireMock", parameters: [ go.parameter({ name: "t", @@ -142,6 +142,28 @@ export class EndpointSnippetGenerator { }) }) ); + writer.writeNode( + go.func({ + name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "Error" + "WithWireMock", + parameters: [ + go.parameter({ + name: "t", + type: go.Type.pointer(go.Type.reference(this.context.getTestingTypeReference())) + }) + ], + return_: [], + body: go.codeblock((writer) => { + for (const node of this.buildWiremockTestSetup({ endpoint, errorCase: true })) { + writer.writeNode(node); + writer.writeLine(); + } + writer.writeLine(); + writer.writeNode(this.constructWiremockTestClient({ endpoint, snippet })); + writer.writeLine(); + writer.writeNode(this.callClientMethodAndAssert({ endpoint, snippet })); + }) + }) + ); writer.writeNewLineIfLastLineNot(); }); } @@ -228,7 +250,7 @@ export class EndpointSnippetGenerator { name: "WithBaseURL", importPath: this.context.getOptionImportPath() }), - arguments_: [go.codeblock(`"http://" + ${WIREMOCK_BASE_URL}`)] + arguments_: [go.codeblock(WIREMOCK_BASE_URL)] }) ); }) @@ -779,102 +801,126 @@ export class EndpointSnippetGenerator { }); } - private buildWiremockTestSetup({ endpoint }: { endpoint: FernIr.dynamic.Endpoint }): go.AstNode[] { - const WIREMOCK_CONTAINER_NAME = "container"; + private buildWiremockTestSetup({ + endpoint, + errorCase + }: { + endpoint: FernIr.dynamic.Endpoint; + errorCase?: boolean; + }): go.AstNode[] { const ENDPOINT_STUB_NAME = "stub"; + const usedSharedMainTest = true; return [ - // Initialize context - go.codeblock((writer) => { - writer.write("ctx := "); - writer.writeNode( - go.invokeFunc({ - func: go.typeReference({ - name: "Background", - importPath: "context" - }), - arguments_: [], - multiline: false - }) - ); - }), - - // Start WireMock container - go.codeblock((writer) => { - writer.write(`${WIREMOCK_CONTAINER_NAME}, containerErr := `); - writer.writeNode( - go.invokeFunc({ - func: go.typeReference({ - name: "RunContainerAndStopOnCleanup", - importPath: "github.com/wiremock/wiremock-testcontainers-go" - }), - arguments_: [ - go.codeblock("ctx"), - go.codeblock("t"), - go.invokeFunc({ - func: go.typeReference({ - name: "WithImage", - importPath: "github.com/wiremock/wiremock-testcontainers-go" - }), - arguments_: [go.TypeInstantiation.string("docker.io/wiremock/wiremock:3.9.1")], - multiline: false - }) - ], - multiline: true - }) - ); - }), - - // Check for container error - go.codeblock((writer) => { - writer.write("if containerErr != nil {"); - writer.writeLine(); - writer.write(" t.Fatal(containerErr)"); - writer.writeLine(); - writer.write("}"); - }), - - // Get WireMock URL from container - go.codeblock((writer) => { - writer.write(`${WIREMOCK_BASE_URL}, endpointErr := `); - writer.writeNode( - go.invokeMethod({ - on: go.codeblock(WIREMOCK_CONTAINER_NAME), - method: "Endpoint", - arguments_: [go.codeblock("ctx"), go.TypeInstantiation.string("")], - multiline: false - }) - ); - }), - - go.invokeFunc({ - func: go.typeReference({ - name: "NoError", - importPath: "github.com/stretchr/testify/require" - }), - arguments_: [ - go.codeblock("t"), - go.codeblock("endpointErr"), - go.TypeInstantiation.string("Failed to get WireMock container endpoint") - ], - multiline: false - }), - - // Get WireMock client from container - go.codeblock((writer) => { - writer.write("wiremockClient := "); - writer.writeNode( - go.selector({ - on: go.codeblock("container"), - selector: go.codeblock("Client") - }) - ); - }), + ...(!usedSharedMainTest + ? [ + // Initialize context + go.codeblock((writer) => { + writer.write("ctx := "); + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "Background", + importPath: "context" + }), + arguments_: [], + multiline: false + }) + ); + }), + + // Start WireMock container + go.codeblock((writer) => { + writer.write(`container, containerErr := `); + writer.writeNode( + go.invokeFunc({ + func: go.typeReference({ + name: "RunContainerAndStopOnCleanup", + importPath: "github.com/wiremock/wiremock-testcontainers-go" + }), + arguments_: [ + go.codeblock("ctx"), + go.codeblock("t"), + go.invokeFunc({ + func: go.typeReference({ + name: "WithImage", + importPath: "github.com/wiremock/wiremock-testcontainers-go" + }), + arguments_: [ + go.TypeInstantiation.string("docker.io/wiremock/wiremock:3.9.1") + ], + multiline: false + }) + ], + multiline: true + }) + ); + }), + + // Check for container error + go.codeblock((writer) => { + writer.write("if containerErr != nil {"); + writer.writeLine(); + writer.write(" t.Fatal(containerErr)"); + writer.writeLine(); + writer.write("}"); + }), + + // Get WireMock URL from container + go.codeblock((writer) => { + writer.write(`${WIREMOCK_BASE_URL}, endpointErr := `); + writer.writeNode( + go.invokeMethod({ + on: go.codeblock("container"), + method: "Endpoint", + arguments_: [go.codeblock("ctx"), go.TypeInstantiation.string("")], + multiline: false + }) + ); + }), + + go.invokeFunc({ + func: go.typeReference({ + name: "NoError", + importPath: "github.com/stretchr/testify/require" + }), + arguments_: [ + go.codeblock("t"), + go.codeblock("endpointErr"), + go.TypeInstantiation.string("Failed to get WireMock container endpoint") + ], + multiline: false + }), + + go.codeblock((writer) => { + writer.write(`${WIREMOCK_BASE_URL} = "http://" + ${WIREMOCK_BASE_URL}`); + }), + + // Get WireMock client from container + go.codeblock((writer) => { + writer.write(`${WIREMOCK_CLIENT_VAR_NAME} := `); + writer.writeNode( + go.selector({ + on: go.codeblock("container"), + selector: go.codeblock("Client") + }) + ); + }) + ] + : []), + + ...(usedSharedMainTest + ? [ + go.codeblock((writer) => { + writer.write("// wiremock client and server initialized in shared main_test.go "); + }) + ] + : []), go.codeblock((writer) => { writer.write("defer "); writer.writeNode( go.invokeMethod({ - on: go.codeblock("wiremockClient"), + on: go.codeblock(WIREMOCK_CLIENT_VAR_NAME), method: "Reset", arguments_: [], multiline: false @@ -905,20 +951,25 @@ export class EndpointSnippetGenerator { multiline: false }), ...(endpoint.request.type === "inlined" && endpoint.request.queryParameters - ? endpoint.request.queryParameters.map((queryParameter: FernIr.dynamic.NamedParameter) => ({ - method: "WithQueryParam", - arguments_: [ - go.TypeInstantiation.string(queryParameter.name.wireValue), - go.invokeFunc({ - func: go.typeReference({ - name: "Matching", - importPath: "github.com/wiremock/go-wiremock" - }), - arguments_: [go.TypeInstantiation.string(".+")], - multiline: false - }) - ] - })) + ? endpoint.request.queryParameters + .filter( + (queryParameter: FernIr.dynamic.NamedParameter) => + queryParameter.typeReference.type !== "optional" + ) + .map((queryParameter: FernIr.dynamic.NamedParameter) => ({ + method: "WithQueryParam", + arguments_: [ + go.TypeInstantiation.string(queryParameter.name.wireValue), + go.invokeFunc({ + func: go.typeReference({ + name: "Matching", + importPath: "github.com/wiremock/go-wiremock" + }), + arguments_: [go.TypeInstantiation.string(".+")], + multiline: false + }) + ] + })) : []), ...(endpoint.request.pathParameters && endpoint.request.pathParameters.length > 0 ? endpoint.request.pathParameters.map((pathParameter: FernIr.dynamic.NamedParameter) => ({ @@ -948,20 +999,35 @@ export class EndpointSnippetGenerator { arguments_: [], multiline: false }), - { - method: "WithJSONBody", - arguments_: [go.codeblock("map[string]interface{}{}")] - }, - { - method: "WithStatus", - arguments_: [ - go.typeReference({ - name: "StatusOK", - importPath: "net/http" - }) - ], - multiline: false - } + ...(errorCase + ? [ + { + method: "WithStatus", + arguments_: [ + go.typeReference({ + name: "StatusInternalServerError", + importPath: "net/http" + }) + ], + multiline: false + } + ] + : [ + { + method: "WithJSONBody", + arguments_: [go.codeblock("map[string]interface{}{}")] + }, + { + method: "WithStatus", + arguments_: [ + go.typeReference({ + name: "StatusOK", + importPath: "net/http" + }) + ], + multiline: false + } + ]) ) ] } @@ -974,7 +1040,7 @@ export class EndpointSnippetGenerator { writer.write("err := "); writer.writeNode( go.invokeMethod({ - on: go.codeblock("wiremockClient"), + on: go.codeblock(WIREMOCK_CLIENT_VAR_NAME), method: "StubFor", arguments_: [go.codeblock(ENDPOINT_STUB_NAME)], multiline: false @@ -1005,8 +1071,11 @@ export class EndpointSnippetGenerator { snippet: FernIr.dynamic.EndpointSnippetRequest; }): go.CodeBlock { return go.codeblock((writer) => { + // IMPORTANT: currently not capturing the response/error values since its not trivial to determine + // the number of return values for the method + // Call the method and capture response and error - writer.write("_, invocationErr := "); + // writer.write("_, invocationErr := "); writer.writeNode( go.invokeMethod({ on: go.codeblock(CLIENT_VAR_NAME), @@ -1024,7 +1093,7 @@ export class EndpointSnippetGenerator { writer.write("ok, countErr := "); writer.writeNode( go.invokeMethod({ - on: go.codeblock("wiremockClient"), + on: go.codeblock(WIREMOCK_CLIENT_VAR_NAME), method: "Verify", arguments_: [ go.invokeMethod({ @@ -1072,23 +1141,26 @@ export class EndpointSnippetGenerator { ); writer.writeLine(); + // IMPORTANT: currently not asserting that the call succeeded since its not trivial to determine + // the number of return values for the method + // Verify the call succeeded (may not assert this at all and only assert the WireMock request was matched) // Since we don't necessarily have valid response bodies in our WireMock stubs (so type casting will fail) - writer.writeNode( - go.invokeFunc({ - func: go.typeReference({ - name: "NoError", - importPath: "github.com/stretchr/testify/require" - }), - arguments_: [ - go.codeblock("t"), - go.codeblock("invocationErr"), - go.TypeInstantiation.string(`${this.getMethod({ endpoint })} call should succeed with WireMock`) - ], - multiline: false - }) - ); - writer.writeLine(); + // writer.writeNode( + // go.invokeFunc({ + // func: go.typeReference({ + // name: "NoError", + // importPath: "github.com/stretchr/testify/require" + // }), + // arguments_: [ + // go.codeblock("t"), + // go.codeblock("invocationErr"), + // go.TypeInstantiation.string(`${this.getMethod({ endpoint })} call should succeed with WireMock`) + // ], + // multiline: false + // }) + // ); + // writer.writeLine(); }); } } diff --git a/generators/go-v2/sdk/src/SdkGeneratorContext.ts b/generators/go-v2/sdk/src/SdkGeneratorContext.ts index 2570629d286..39953f69d95 100644 --- a/generators/go-v2/sdk/src/SdkGeneratorContext.ts +++ b/generators/go-v2/sdk/src/SdkGeneratorContext.ts @@ -60,7 +60,7 @@ export class SdkGeneratorContext extends AbstractGoGeneratorContext Date: Thu, 11 Sep 2025 12:26:18 -0400 Subject: [PATCH 06/18] do not include error case for now --- .../src/EndpointSnippetGenerator.ts | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index b41e36fede5..9dbc5595ff8 100644 --- a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -142,28 +142,30 @@ export class EndpointSnippetGenerator { }) }) ); - writer.writeNode( - go.func({ - name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "Error" + "WithWireMock", - parameters: [ - go.parameter({ - name: "t", - type: go.Type.pointer(go.Type.reference(this.context.getTestingTypeReference())) - }) - ], - return_: [], - body: go.codeblock((writer) => { - for (const node of this.buildWiremockTestSetup({ endpoint, errorCase: true })) { - writer.writeNode(node); - writer.writeLine(); - } - writer.writeLine(); - writer.writeNode(this.constructWiremockTestClient({ endpoint, snippet })); - writer.writeLine(); - writer.writeNode(this.callClientMethodAndAssert({ endpoint, snippet })); - }) - }) - ); + // Not including error case for now until I can figure out why its not matching the wiremock stub + // writer.writeLine(); + // writer.writeNode( + // go.func({ + // name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "Error" + "WithWireMock", + // parameters: [ + // go.parameter({ + // name: "t", + // type: go.Type.pointer(go.Type.reference(this.context.getTestingTypeReference())) + // }) + // ], + // return_: [], + // body: go.codeblock((writer) => { + // for (const node of this.buildWiremockTestSetup({ endpoint, errorCase: true })) { + // writer.writeNode(node); + // writer.writeLine(); + // } + // writer.writeLine(); + // writer.writeNode(this.constructWiremockTestClient({ endpoint, snippet })); + // writer.writeLine(); + // writer.writeNode(this.callClientMethodAndAssert({ endpoint, snippet })); + // }) + // }) + // ); writer.writeNewLineIfLastLineNot(); }); } @@ -952,6 +954,7 @@ export class EndpointSnippetGenerator { }), ...(endpoint.request.type === "inlined" && endpoint.request.queryParameters ? endpoint.request.queryParameters + //Exclude optional since it appears that optional query params do not automatically get mocked in the dynamic snippet invocation (for now) .filter( (queryParameter: FernIr.dynamic.NamedParameter) => queryParameter.typeReference.type !== "optional" From c30be7002770ee59b86833c07026ecb27400e5e0 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Thu, 11 Sep 2025 16:41:31 -0400 Subject: [PATCH 07/18] remove unnecessary safe escape code --- .../go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts b/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts index bcee4351b37..97770aa89a1 100644 --- a/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts +++ b/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts @@ -29,10 +29,6 @@ export class EndpointSnippetsGenerator { } public async populateSnippetsCache(): Promise { - if (this.snippetsCacheInitialized) { - return; - } - const endpointSnippetsById = new Map(); const dynamicIr = this.context.ir.dynamic; @@ -102,8 +98,6 @@ export class EndpointSnippetsGenerator { endpointSnippetsById.forEach((value, key) => { this.snippetsCache.set(key, value); }); - - this.snippetsCacheInitialized = true; } public getSnippetsForEndpoint(endpointId: string): EndpointSnippets | undefined { From cdbae09fd2ea093ce41dee64506c7b73dbb76564 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Mon, 15 Sep 2025 11:24:22 -0400 Subject: [PATCH 08/18] rename tests to avoid collisions --- .../go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts | 2 +- .../src/context/DynamicSnippetsGeneratorContext.ts | 7 +++++++ generators/go-v2/sdk/src/SdkGeneratorCli.ts | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index 9dbc5595ff8..b60e3922d2a 100644 --- a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -122,7 +122,7 @@ export class EndpointSnippetGenerator { return go.codeblock((writer) => { writer.writeNode( go.func({ - name: "Test" + this.context.getMethodName(endpoint.declaration.name) + "WithWireMock", + name: "Test" + this.context.getTestMethodName(endpoint) + "WithWireMock", parameters: [ go.parameter({ name: "t", diff --git a/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts b/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts index ace3e15c9b7..27e7960a6d8 100644 --- a/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts +++ b/generators/go-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts @@ -44,6 +44,13 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene return name.pascalCase.unsafeName; } + public getTestMethodName(endpoint: FernIr.dynamic.Endpoint): string { + return ( + endpoint.declaration.fernFilepath.allParts.map((name) => name.pascalCase.unsafeName).join("") + + endpoint.declaration.name.pascalCase.unsafeName + ); + } + public getTypeName(name: FernIr.Name): string { return name.pascalCase.unsafeName; } diff --git a/generators/go-v2/sdk/src/SdkGeneratorCli.ts b/generators/go-v2/sdk/src/SdkGeneratorCli.ts index a14a0d63157..199c57e2ebc 100644 --- a/generators/go-v2/sdk/src/SdkGeneratorCli.ts +++ b/generators/go-v2/sdk/src/SdkGeneratorCli.ts @@ -203,7 +203,9 @@ export class SdkGeneratorCLI extends AbstractGoGeneratorCli name.snakeCase.safeName).join("_") || "root"; + const wiremockTestFilename = servicePath + "_" + endpoint.declaration.name.snakeCase.safeName + "_test.go"; const wiremockTestContent = dynamicSnippetsGenerator.generateSync( convertDynamicEndpointSnippetRequest(endpointExample), { config: { outputWiremockTests: true } } From bcf12abe658dd554ddd283fe4d8a6182429815d0 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 13:42:38 -0400 Subject: [PATCH 09/18] final fixes for now --- .../custom-config/BaseGoCustomConfigSchema.ts | 3 +- .../src/context/AbstractGoGeneratorContext.ts | 2 +- .../go-v2/base/src/project/GoProject.ts | 2 +- .../src/EndpointSnippetGenerator.ts | 4 +- .../go-v2/model/src/ModelGeneratorContext.ts | 2 +- generators/go-v2/sdk/src/SdkGeneratorCli.ts | 46 +-- .../go-v2/sdk/src/SdkGeneratorContext.ts | 2 +- .../sdk/src/wire-tests/WireTestGenerator.ts | 290 ++++++++++++++++++ seed/go-sdk/seed.yml | 3 + 9 files changed, 313 insertions(+), 41 deletions(-) create mode 100644 generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts diff --git a/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts b/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts index 2678760e283..accba907751 100644 --- a/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts +++ b/generators/go-v2/ast/src/custom-config/BaseGoCustomConfigSchema.ts @@ -20,7 +20,8 @@ export const baseGoCustomConfigSchema = z.object({ union: z.enum(["v0", "v1"]).optional(), useReaderForBytesRequest: z.boolean().optional(), useDefaultRequestParameterValues: z.boolean().optional(), - gettersPassByValue: z.boolean().optional() + gettersPassByValue: z.boolean().optional(), + enableWireTests: z.boolean().optional() }); export type BaseGoCustomConfigSchema = z.infer; diff --git a/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts b/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts index 2cf9bd6a730..47381e6c686 100644 --- a/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts +++ b/generators/go-v2/base/src/context/AbstractGoGeneratorContext.ts @@ -668,5 +668,5 @@ export abstract class AbstractGoGeneratorContext< public abstract getInternalAsIsFiles(): string[]; - public abstract getInternalTestAsIsFiles(): string[]; + public abstract getTestAsIsFiles(): string[]; } diff --git a/generators/go-v2/base/src/project/GoProject.ts b/generators/go-v2/base/src/project/GoProject.ts index 87d14c7a3d7..f6f87d09630 100644 --- a/generators/go-v2/base/src/project/GoProject.ts +++ b/generators/go-v2/base/src/project/GoProject.ts @@ -91,7 +91,7 @@ export class GoProject extends AbstractProject { const sharedTestFiles = await Promise.all( - this.context.getInternalTestAsIsFiles().map(async (filename) => await this.createAsIsFile({ filename })) + this.context.getTestAsIsFiles().map(async (filename) => await this.createAsIsFile({ filename })) ); return await this.createGoDirectory({ diff --git a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index b60e3922d2a..f96e05c40c7 100644 --- a/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/go-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -1075,7 +1075,7 @@ export class EndpointSnippetGenerator { }): go.CodeBlock { return go.codeblock((writer) => { // IMPORTANT: currently not capturing the response/error values since its not trivial to determine - // the number of return values for the method + // the number of return values for the method using the dynamic ir // Call the method and capture response and error // writer.write("_, invocationErr := "); @@ -1145,7 +1145,7 @@ export class EndpointSnippetGenerator { writer.writeLine(); // IMPORTANT: currently not asserting that the call succeeded since its not trivial to determine - // the number of return values for the method + // the number of return values for the method using the dynamic ir // Verify the call succeeded (may not assert this at all and only assert the WireMock request was matched) // Since we don't necessarily have valid response bodies in our WireMock stubs (so type casting will fail) diff --git a/generators/go-v2/model/src/ModelGeneratorContext.ts b/generators/go-v2/model/src/ModelGeneratorContext.ts index ec1a3c0baa1..aca080eb612 100644 --- a/generators/go-v2/model/src/ModelGeneratorContext.ts +++ b/generators/go-v2/model/src/ModelGeneratorContext.ts @@ -34,7 +34,7 @@ export class ModelGeneratorContext extends AbstractGoGeneratorContext { protected constructContext({ @@ -59,10 +60,17 @@ export class SdkGeneratorCLI extends AbstractGoGeneratorCli { - const dynamicIr = context.ir.dynamic; - if (dynamicIr == null) { - throw new Error("Cannot generate wiremock tests without dynamic IR"); - } - - const dynamicSnippetsGenerator = new DynamicSnippetsGenerator({ - ir: convertIr(dynamicIr), - config: context.config - }); - - for (const endpoint of Object.values(dynamicIr.endpoints)) { - const endpointExample = endpoint.examples?.[0]; - if (endpointExample == null) { - continue; - } - const servicePath = - endpoint.declaration.fernFilepath.allParts.map((name) => name.snakeCase.safeName).join("_") || "root"; - const wiremockTestFilename = servicePath + "_" + endpoint.declaration.name.snakeCase.safeName + "_test.go"; - const wiremockTestContent = dynamicSnippetsGenerator.generateSync( - convertDynamicEndpointSnippetRequest(endpointExample), - { config: { outputWiremockTests: true } } - ).snippet; - - context.project.addRawFiles( - new File(wiremockTestFilename, RelativeFilePath.of("./test"), wiremockTestContent) - ); - } - } - private async generateReadme({ context, endpointSnippets diff --git a/generators/go-v2/sdk/src/SdkGeneratorContext.ts b/generators/go-v2/sdk/src/SdkGeneratorContext.ts index 39953f69d95..3a92376df26 100644 --- a/generators/go-v2/sdk/src/SdkGeneratorContext.ts +++ b/generators/go-v2/sdk/src/SdkGeneratorContext.ts @@ -59,7 +59,7 @@ export class SdkGeneratorContext extends AbstractGoGeneratorContext { + await this.context.project.writeSharedTestFiles(); + + const endpointsByService = this.groupEndpointsByService(); + + for (const [serviceName, endpoints] of endpointsByService.entries()) { + const endpointsWithExamples = endpoints.filter((endpoint) => { + const dynamicEndpoint = this.dynamicIr.endpoints[endpoint.id]; + return dynamicEndpoint?.examples && dynamicEndpoint.examples.length > 0; + }); + + if (endpointsWithExamples.length === 0) { + continue; + } + + const serviceTestFile = await this.generateServiceTestFile(serviceName, endpointsWithExamples); + + this.context.project.addRawFiles(serviceTestFile); + } + } + + private async generateServiceTestFile(serviceName: string, endpoints: HttpEndpoint[]): Promise { + const endpointTestCases = new Map(); + for (const endpoint of endpoints) { + const dynamicEndpoint = this.dynamicIr.endpoints[endpoint.id]; + if (dynamicEndpoint?.examples && dynamicEndpoint.examples.length > 0) { + const firstExample = dynamicEndpoint.examples[0]; + if (firstExample) { + try { + const snippet = await this.generateSnippetForExample(firstExample); + endpointTestCases.set(endpoint.id, snippet); + } catch (error) { + this.context.logger.warn(`Failed to generate snippet for endpoint ${endpoint.id}: ${error}`); + // Skip this endpoint if snippet generation fails + continue; + } + } + } + } + + const imports = new Map(); + const endpointTestCaseCodeBlocks = endpoints + .map((endpoint) => { + const snippet = endpointTestCases.get(endpoint.id); + if (!snippet) { + this.context.logger.warn(`No snippet found for endpoint ${endpoint.id}`); + return null; + } + const [endpointTestCaseCodeBlock, endpointImports] = this.parseEndpointTestCaseSnippet(snippet); + for (const [importName, importPath] of endpointImports.entries()) { + imports.set(importName, importPath); + } + + return endpointTestCaseCodeBlock; + }) + .filter((endpointTestCaseCodeBlock) => endpointTestCaseCodeBlock !== null); + + const serviceTestFileContent = go + .codeblock((writer) => { + this.writeImports(writer, imports); + writer.writeNewLineIfLastLineNot(); + writer.writeLine(); + for (const endpointTestCaseCodeBlock of endpointTestCaseCodeBlocks) { + writer.write(endpointTestCaseCodeBlock); + writer.writeNewLineIfLastLineNot(); + writer.writeLine(); + } + }) + .toString({ + packageName: "wiremock", + rootImportPath: this.context.getRootPackageName(), + importPath: this.context.getRootPackageName(), + customConfig: this.context.customConfig ?? {}, + formatter: undefined + }); + + return new File(serviceName + "_test.go", RelativeFilePath.of("./test"), serviceTestFileContent); + } + + private async generateSnippetForExample(example: dynamic.EndpointExample): Promise { + const snippetRequest = convertDynamicEndpointSnippetRequest(example); + const response = await this.dynamicSnippetsGenerator.generate(snippetRequest, { + config: { outputWiremockTests: true } + }); + if (!response.snippet) { + throw new Error("No snippet generated for example"); + } + return response.snippet; + } + + private parseEndpointTestCaseSnippet(fileString: string): [string, Map] { + const imports = new Map(); + const lines = fileString.split("\n"); + + let inImportBlock = false; + let testMethodStart = -1; + let braceCount = 0; + let testMethodEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim() ?? ""; + + // Parse import statements + if (line === "import (") { + inImportBlock = true; + continue; + } + + if (inImportBlock) { + if (line === ")") { + inImportBlock = false; + continue; + } + + // Parse import with alias: alias "path" + const importMatch = line.match(/^(\w+)\s+"([^"]+)"/); + if (importMatch && importMatch[1] && importMatch[2]) { + const [, alias, path] = importMatch; + imports.set(alias, path); + } + } + + // Find test method start + if (line.startsWith("func") && testMethodStart === -1) { + testMethodStart = i; + } + + // Count braces to find test method end + if (testMethodStart !== -1 && testMethodEnd === -1) { + for (const char of line) { + if (char === "{") { + braceCount++; + } else if (char === "}") { + braceCount--; + if (braceCount === 0) { + testMethodEnd = i; + break; + } + } + } + } + } + + // Extract test method content + let testMethodContent = ""; + if (testMethodStart !== -1 && testMethodEnd !== -1) { + testMethodContent = lines.slice(testMethodStart, testMethodEnd + 1).join("\n") + "\n"; + } + + return [testMethodContent, imports]; + } + + private writeImports(writer: any, imports: Map): void { + const standardLibraryImports: string[] = []; + const externalImports: Array<[string, string]> = []; + + for (const [alias, importPath] of imports.entries()) { + if (this.isStandardLibraryImport(importPath)) { + standardLibraryImports.push(importPath); + } else { + externalImports.push([alias, importPath]); + } + } + + standardLibraryImports.sort(); + externalImports.sort(([, pathA], [, pathB]) => pathA.localeCompare(pathB)); + + writer.writeLine("import ("); + for (const importPath of standardLibraryImports) { + writer.writeLine(`\t"${importPath}"`); + } + if (standardLibraryImports.length > 0 && externalImports.length > 0) { + writer.writeLine(); + } + for (const [alias, importPath] of externalImports) { + writer.writeLine(`\t${alias} "${importPath}"`); + } + + writer.writeLine(")"); + } + + private isStandardLibraryImport(importPath: string): boolean { + const standardLibraryPackages = [ + "context", + "fmt", + "io", + "net", + "net/http", + "os", + "strings", + "testing", + "time", + "encoding/json", + "encoding/base64", + "crypto/md5", + "crypto/sha1", + "crypto/sha256", + "crypto/sha512", + "hash", + "math", + "math/rand", + "sort", + "strconv", + "sync", + "unicode", + "unicode/utf8", + "unsafe", + "reflect", + "runtime", + "path", + "path/filepath", + "regexp", + "bytes", + "bufio", + "compress/gzip", + "compress/flate", + "archive/tar", + "archive/zip", + "database/sql", + "database/sql/driver", + "html", + "html/template", + "image", + "image/color", + "image/draw", + "image/gif", + "image/jpeg", + "image/png", + "log", + "log/syslog", + "mime", + "mime/multipart", + "net/mail", + "net/smtp", + "net/textproto", + "net/url", + "net/rpc", + "net/rpc/jsonrpc", + "plugin", + "text", + "text/scanner", + "text/tabwriter", + "text/template", + "text/template/parse", + "vendor" + ]; + + const path = importPath.replace(/"/g, ""); + return !path.includes(".") || standardLibraryPackages.includes(path); + } + + private groupEndpointsByService(): Map { + const endpointsByService = new Map(); + + for (const service of Object.values(this.context.ir.services)) { + const serviceName = + service.name?.fernFilepath?.allParts?.map((part) => part.snakeCase.safeName).join("_") || "root"; + + endpointsByService.set(serviceName, service.endpoints); + } + + return endpointsByService; + } +} diff --git a/seed/go-sdk/seed.yml b/seed/go-sdk/seed.yml index c7ce718541a..3f82bb97774 100644 --- a/seed/go-sdk/seed.yml +++ b/seed/go-sdk/seed.yml @@ -80,6 +80,9 @@ fixtures: customConfig: useReaderForBytesRequest: true imdb: + - outputFolder: with-wiremock-tests + customConfig: + enableWireTests: true - outputFolder: no-custom-config customConfig: null - outputFolder: package-path From 4e98e59d6ecd1121b248f2d42b8f88036fc2c4df Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 13:49:43 -0400 Subject: [PATCH 10/18] add illustrative wire test seed fixture --- .../.github/workflows/ci.yml | 27 ++ .../go-sdk/imdb/with-wiremock-tests/README.md | 157 +++++++ .../imdb/with-wiremock-tests/client/client.go | 33 ++ .../with-wiremock-tests/client/client_test.go | 45 ++ .../with-wiremock-tests/core/api_error.go | 47 +++ .../imdb/with-wiremock-tests/core/http.go | 15 + .../core/request_option.go | 125 ++++++ .../dynamic-snippets/example0/snippet.go | 26 ++ .../dynamic-snippets/example1/snippet.go | 22 + .../dynamic-snippets/example2/snippet.go | 22 + .../go-sdk/imdb/with-wiremock-tests/errors.go | 31 ++ .../imdb/with-wiremock-tests/file_param.go | 41 ++ seed/go-sdk/imdb/with-wiremock-tests/go.mod | 67 +++ seed/go-sdk/imdb/with-wiremock-tests/go.sum | 201 +++++++++ seed/go-sdk/imdb/with-wiremock-tests/imdb.go | 128 ++++++ .../imdb/with-wiremock-tests/imdb/client.go | 66 +++ .../with-wiremock-tests/imdb/raw_client.go | 123 ++++++ .../with-wiremock-tests/internal/caller.go | 250 +++++++++++ .../internal/caller_test.go | 395 ++++++++++++++++++ .../internal/error_decoder.go | 47 +++ .../internal/error_decoder_test.go | 59 +++ .../internal/extra_properties.go | 141 +++++++ .../internal/extra_properties_test.go | 228 ++++++++++ .../imdb/with-wiremock-tests/internal/http.go | 48 +++ .../with-wiremock-tests/internal/query.go | 350 ++++++++++++++++ .../internal/query_test.go | 374 +++++++++++++++++ .../with-wiremock-tests/internal/retrier.go | 213 ++++++++++ .../internal/retrier_test.go | 310 ++++++++++++++ .../with-wiremock-tests/internal/stringer.go | 13 + .../imdb/with-wiremock-tests/internal/time.go | 137 ++++++ .../option/request_option.go | 71 ++++ .../imdb/with-wiremock-tests/pointer.go | 132 ++++++ .../imdb/with-wiremock-tests/reference.md | 106 +++++ .../snippet-templates.json | 0 .../imdb/with-wiremock-tests/snippet.json | 26 ++ .../with-wiremock-tests/test/imdb_test.go | 72 ++++ .../with-wiremock-tests/test/main_test.go | 57 +++ 37 files changed, 4205 insertions(+) create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/.github/workflows/ci.yml create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/README.md create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/client/client.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/client/client_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/core/api_error.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/core/http.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/core/request_option.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example0/snippet.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example1/snippet.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example2/snippet.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/errors.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/file_param.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/go.mod create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/go.sum create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/imdb.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/imdb/client.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/imdb/raw_client.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/caller.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/caller_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/extra_properties.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/extra_properties_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/http.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/query.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/query_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/retrier.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/retrier_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/stringer.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/internal/time.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/option/request_option.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/pointer.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/reference.md create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/snippet-templates.json create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/snippet.json create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/test/imdb_test.go create mode 100644 seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go diff --git a/seed/go-sdk/imdb/with-wiremock-tests/.github/workflows/ci.yml b/seed/go-sdk/imdb/with-wiremock-tests/.github/workflows/ci.yml new file mode 100644 index 00000000000..6558e5bb07d --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: [push] + +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 ./... + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v4 + + - name: Test + run: go test ./... diff --git a/seed/go-sdk/imdb/with-wiremock-tests/README.md b/seed/go-sdk/imdb/with-wiremock-tests/README.md new file mode 100644 index 00000000000..c301e1c7603 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/README.md @@ -0,0 +1,157 @@ +# 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. + +## 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 ( + client "github.com/imdb/fern/client" + option "github.com/imdb/fern/option" + context "context" + fern "github.com/imdb/fern" +) + +func do() { + client := client.NewClient( + option.WithToken( + "", + ), + ) + client.Imdb.CreateMovie( + context.TODO(), + &fern.CreateMovieRequest{ + Title: "title", + Rating: 1.1, + }, + ) +} +``` + +## 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.Imdb.CreateMovie(...) +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.WithToken(""), + option.WithHTTPClient( + &http.Client{ + Timeout: 5 * time.Second, + }, + ), +) + +// Specify options for an individual request. +response, err := client.Imdb.CreateMovie( + ..., + option.WithToken(""), +) +``` + +## 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. + +```go +response, err := client.Imdb.WithRawResponse.CreateMovie(...) +if err != nil { + return err +} +fmt.Printf("Got response headers: %v", response.Header) +``` + +### 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). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [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/500) (Internal Server Errors) + +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.Imdb.CreateMovie( + ..., + 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.Imdb.CreateMovie(ctx, ...) +``` + +## 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! \ No newline at end of file diff --git a/seed/go-sdk/imdb/with-wiremock-tests/client/client.go b/seed/go-sdk/imdb/with-wiremock-tests/client/client.go new file mode 100644 index 00000000000..57cea0eefee --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/client/client.go @@ -0,0 +1,33 @@ +// Code generated by Fern. DO NOT EDIT. + +package client + +import ( + core "github.com/imdb/fern/core" + imdb "github.com/imdb/fern/imdb" + internal "github.com/imdb/fern/internal" + option "github.com/imdb/fern/option" +) + +type Client struct { + Imdb *imdb.Client + + options *core.RequestOptions + baseURL string + caller *internal.Caller +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + Imdb: imdb.NewClient(options), + options: options, + baseURL: options.BaseURL, + caller: internal.NewCaller( + &internal.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + } +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/client/client_test.go b/seed/go-sdk/imdb/with-wiremock-tests/client/client_test.go new file mode 100644 index 00000000000..5c2f117febb --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/client/client_test.go @@ -0,0 +1,45 @@ +// Code generated by Fern. DO NOT EDIT. + +package client + +import ( + option "github.com/imdb/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/imdb/with-wiremock-tests/core/api_error.go b/seed/go-sdk/imdb/with-wiremock-tests/core/api_error.go new file mode 100644 index 00000000000..6168388541b --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/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/imdb/with-wiremock-tests/core/http.go b/seed/go-sdk/imdb/with-wiremock-tests/core/http.go new file mode 100644 index 00000000000..92c43569294 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/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/imdb/with-wiremock-tests/core/request_option.go b/seed/go-sdk/imdb/with-wiremock-tests/core/request_option.go new file mode 100644 index 00000000000..f39dea273ed --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/core/request_option.go @@ -0,0 +1,125 @@ +// Code generated by Fern. DO NOT EDIT. + +package core + +import ( + 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 + Token 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.Token != "" { + header.Set("Authorization", "Bearer "+r.Token) + } + 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/imdb/fern") + headers.Set("X-Fern-SDK-Version", "0.0.1") + headers.Set("User-Agent", "github.com/imdb/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 +} + +// TokenOption implements the RequestOption interface. +type TokenOption struct { + Token string +} + +func (t *TokenOption) applyRequestOptions(opts *RequestOptions) { + opts.Token = t.Token +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example0/snippet.go b/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example0/snippet.go new file mode 100644 index 00000000000..48b625fc261 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example0/snippet.go @@ -0,0 +1,26 @@ +package example + +import ( + client "github.com/imdb/fern/client" + option "github.com/imdb/fern/option" + context "context" + fern "github.com/imdb/fern" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + option.WithToken( + "", + ), + ) + client.Imdb.CreateMovie( + context.TODO(), + &fern.CreateMovieRequest{ + Title: "title", + Rating: 1.1, + }, + ) +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example1/snippet.go b/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example1/snippet.go new file mode 100644 index 00000000000..b1ce7056c17 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example1/snippet.go @@ -0,0 +1,22 @@ +package example + +import ( + client "github.com/imdb/fern/client" + option "github.com/imdb/fern/option" + context "context" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + option.WithToken( + "", + ), + ) + client.Imdb.GetMovie( + context.TODO(), + "movieId", + ) +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example2/snippet.go b/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example2/snippet.go new file mode 100644 index 00000000000..b1ce7056c17 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/dynamic-snippets/example2/snippet.go @@ -0,0 +1,22 @@ +package example + +import ( + client "github.com/imdb/fern/client" + option "github.com/imdb/fern/option" + context "context" +) + +func do() { + client := client.NewClient( + option.WithBaseURL( + "https://api.fern.com", + ), + option.WithToken( + "", + ), + ) + client.Imdb.GetMovie( + context.TODO(), + "movieId", + ) +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/errors.go b/seed/go-sdk/imdb/with-wiremock-tests/errors.go new file mode 100644 index 00000000000..0385af6e35b --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/errors.go @@ -0,0 +1,31 @@ +// Code generated by Fern. DO NOT EDIT. + +package api + +import ( + json "encoding/json" + core "github.com/imdb/fern/core" +) + +type MovieDoesNotExistError struct { + *core.APIError + Body MovieId +} + +func (m *MovieDoesNotExistError) UnmarshalJSON(data []byte) error { + var body MovieId + if err := json.Unmarshal(data, &body); err != nil { + return err + } + m.StatusCode = 404 + m.Body = body + return nil +} + +func (m *MovieDoesNotExistError) MarshalJSON() ([]byte, error) { + return json.Marshal(m.Body) +} + +func (m *MovieDoesNotExistError) Unwrap() error { + return m.APIError +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/file_param.go b/seed/go-sdk/imdb/with-wiremock-tests/file_param.go new file mode 100644 index 00000000000..737abb97c83 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/file_param.go @@ -0,0 +1,41 @@ +package api + +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/imdb/with-wiremock-tests/go.mod b/seed/go-sdk/imdb/with-wiremock-tests/go.mod new file mode 100644 index 00000000000..ef43e519b69 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/go.mod @@ -0,0 +1,67 @@ +module github.com/imdb/fern + +go 1.21 + +toolchain go1.23.8 + +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.9.0 + github.com/wiremock/go-wiremock v1.13.0 + github.com/wiremock/wiremock-testcontainers-go v1.0.0-alpha-9 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/containerd v1.7.15 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/docker v25.0.5+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/klauspost/compress v1.16.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/testcontainers/testcontainers-go v0.31.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/mod v0.16.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/tools v0.13.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/seed/go-sdk/imdb/with-wiremock-tests/go.sum b/seed/go-sdk/imdb/with-wiremock-tests/go.sum new file mode 100644 index 00000000000..f043a1f09dd --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/go.sum @@ -0,0 +1,201 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/containerd v1.7.15 h1:afEHXdil9iAm03BmhjzKyXnnEBtjaLJefdU7DV0IFes= +github.com/containerd/containerd v1.7.15/go.mod h1:ISzRRTMF8EXNpJlTzyr2XMhN+j9K302C21/+cr3kUnY= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= +github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.31.0 h1:W0VwIhcEVhRflwL9as3dhY6jXjVCA27AkmbnZ+UTh3U= +github.com/testcontainers/testcontainers-go v0.31.0/go.mod h1:D2lAoA0zUFiSY+eAflqK5mcUx/A5hrrORaEQrd0SefI= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/wiremock/go-wiremock v1.13.0 h1:bohW8GDACMR4kf0DPGo9vp6iEvce80Zq8KTEMu0n3Dc= +github.com/wiremock/go-wiremock v1.13.0/go.mod h1:3poFKPXvZAuWQAVUzMqw6CZCUYEDAEcYUNc8DZOU8Y8= +github.com/wiremock/wiremock-testcontainers-go v1.0.0-alpha-9 h1:LUa3up/6uLDRo6U++9VtLHfsJKQjwqHyHmdXuT3cqdU= +github.com/wiremock/wiremock-testcontainers-go v1.0.0-alpha-9/go.mod h1:nWMqyEkwfGVBm8gOpQ41RhUWUqSfsFhlIrxMtO9NziU= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g= +google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:FmF5cCW94Ij59cfpoLiwTgodWmm60eEV0CjlsVg2fuw= +google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d h1:pgIUhmqwKOUlnKna4r6amKdUngdL8DrkpFeV8+VBElY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/seed/go-sdk/imdb/with-wiremock-tests/imdb.go b/seed/go-sdk/imdb/with-wiremock-tests/imdb.go new file mode 100644 index 00000000000..f0d5cbf2e4f --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/imdb.go @@ -0,0 +1,128 @@ +// Code generated by Fern. DO NOT EDIT. + +package api + +import ( + json "encoding/json" + fmt "fmt" + internal "github.com/imdb/fern/internal" +) + +type CreateMovieRequest struct { + Title string `json:"title" url:"title"` + Rating float64 `json:"rating" url:"rating"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (c *CreateMovieRequest) GetTitle() string { + if c == nil { + return "" + } + return c.Title +} + +func (c *CreateMovieRequest) GetRating() float64 { + if c == nil { + return 0 + } + return c.Rating +} + +func (c *CreateMovieRequest) GetExtraProperties() map[string]interface{} { + return c.extraProperties +} + +func (c *CreateMovieRequest) UnmarshalJSON(data []byte) error { + type unmarshaler CreateMovieRequest + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *c = CreateMovieRequest(value) + extraProperties, err := internal.ExtractExtraProperties(data, *c) + if err != nil { + return err + } + c.extraProperties = extraProperties + c.rawJSON = json.RawMessage(data) + return nil +} + +func (c *CreateMovieRequest) String() string { + if len(c.rawJSON) > 0 { + if value, err := internal.StringifyJSON(c.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(c); err == nil { + return value + } + return fmt.Sprintf("%#v", c) +} + +type Movie struct { + Id MovieId `json:"id" url:"id"` + Title string `json:"title" url:"title"` + // The rating scale is one to five stars + Rating float64 `json:"rating" url:"rating"` + + extraProperties map[string]interface{} + rawJSON json.RawMessage +} + +func (m *Movie) GetId() MovieId { + if m == nil { + return "" + } + return m.Id +} + +func (m *Movie) GetTitle() string { + if m == nil { + return "" + } + return m.Title +} + +func (m *Movie) GetRating() float64 { + if m == nil { + return 0 + } + return m.Rating +} + +func (m *Movie) GetExtraProperties() map[string]interface{} { + return m.extraProperties +} + +func (m *Movie) UnmarshalJSON(data []byte) error { + type unmarshaler Movie + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *m = Movie(value) + extraProperties, err := internal.ExtractExtraProperties(data, *m) + if err != nil { + return err + } + m.extraProperties = extraProperties + m.rawJSON = json.RawMessage(data) + return nil +} + +func (m *Movie) String() string { + if len(m.rawJSON) > 0 { + if value, err := internal.StringifyJSON(m.rawJSON); err == nil { + return value + } + } + if value, err := internal.StringifyJSON(m); err == nil { + return value + } + return fmt.Sprintf("%#v", m) +} + +type MovieId = string diff --git a/seed/go-sdk/imdb/with-wiremock-tests/imdb/client.go b/seed/go-sdk/imdb/with-wiremock-tests/imdb/client.go new file mode 100644 index 00000000000..ede9c81fa29 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/imdb/client.go @@ -0,0 +1,66 @@ +// Code generated by Fern. DO NOT EDIT. + +package imdb + +import ( + context "context" + fern "github.com/imdb/fern" + core "github.com/imdb/fern/core" + internal "github.com/imdb/fern/internal" + option "github.com/imdb/fern/option" +) + +type Client struct { + WithRawResponse *RawClient + + options *core.RequestOptions + baseURL string + caller *internal.Caller +} + +func NewClient(options *core.RequestOptions) *Client { + return &Client{ + WithRawResponse: NewRawClient(options), + options: options, + baseURL: options.BaseURL, + caller: internal.NewCaller( + &internal.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + } +} + +// Add a movie to the database using the movies/* /... path. +func (c *Client) CreateMovie( + ctx context.Context, + request *fern.CreateMovieRequest, + opts ...option.RequestOption, +) (fern.MovieId, error) { + response, err := c.WithRawResponse.CreateMovie( + ctx, + request, + opts..., + ) + if err != nil { + return "", err + } + return response.Body, nil +} + +func (c *Client) GetMovie( + ctx context.Context, + movieId fern.MovieId, + opts ...option.RequestOption, +) (*fern.Movie, error) { + response, err := c.WithRawResponse.GetMovie( + ctx, + movieId, + opts..., + ) + if err != nil { + return nil, err + } + return response.Body, nil +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/imdb/raw_client.go b/seed/go-sdk/imdb/with-wiremock-tests/imdb/raw_client.go new file mode 100644 index 00000000000..49ac5c1bfbd --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/imdb/raw_client.go @@ -0,0 +1,123 @@ +// Code generated by Fern. DO NOT EDIT. + +package imdb + +import ( + context "context" + fern "github.com/imdb/fern" + core "github.com/imdb/fern/core" + internal "github.com/imdb/fern/internal" + option "github.com/imdb/fern/option" + http "net/http" +) + +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, + }, + ), + } +} + +func (r *RawClient) CreateMovie( + ctx context.Context, + request *fern.CreateMovieRequest, + opts ...option.RequestOption, +) (*core.Response[fern.MovieId], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "", + ) + endpointURL := baseURL + "/movies/create-movie" + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + var response fern.MovieId + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + Headers: headers, + MaxAttempts: options.MaxAttempts, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ) + if err != nil { + return nil, err + } + return &core.Response[fern.MovieId]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} + +func (r *RawClient) GetMovie( + ctx context.Context, + movieId fern.MovieId, + opts ...option.RequestOption, +) (*core.Response[*fern.Movie], error) { + options := core.NewRequestOptions(opts...) + baseURL := internal.ResolveBaseURL( + options.BaseURL, + r.baseURL, + "", + ) + endpointURL := internal.EncodeURL( + baseURL+"/movies/%v", + movieId, + ) + headers := internal.MergeHeaders( + r.options.ToHeader(), + options.ToHeader(), + ) + errorCodes := internal.ErrorCodes{ + 404: func(apiError *core.APIError) error { + return &fern.MovieDoesNotExistError{ + APIError: apiError, + } + }, + } + var response *fern.Movie + raw, err := r.caller.Call( + ctx, + &internal.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + Headers: headers, + MaxAttempts: options.MaxAttempts, + BodyProperties: options.BodyProperties, + QueryParameters: options.QueryParameters, + Client: options.HTTPClient, + Response: &response, + ErrorDecoder: internal.NewErrorDecoder(errorCodes), + }, + ) + if err != nil { + return nil, err + } + return &core.Response[*fern.Movie]{ + StatusCode: raw.StatusCode, + Header: raw.Header, + Body: response, + }, nil +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/internal/caller.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/caller.go new file mode 100644 index 00000000000..49a475bf422 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/caller.go @@ -0,0 +1,250 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/imdb/fern/core" +) + +const ( + // contentType specifies the JSON Content-Type header value. + contentType = "application/json" + contentTypeHeader = "Content-Type" +) + +// 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 +} + +// 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 + } + var retryOptions []RetryOption + if params.MaxAttempts > 0 { + retryOptions = append(retryOptions, WithMaxAttempts(params.MaxAttempts)) + } + return &Caller{ + client: httpClient, + retrier: NewRetrier(retryOptions...), + } +} + +// CallParams represents the parameters used to issue an API call. +type CallParams struct { + URL string + Method string + MaxAttempts uint + 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 + } + + var retryOptions []RetryOption + if params.MaxAttempts > 0 { + retryOptions = append(retryOptions, WithMaxAttempts(params.MaxAttempts)) + } + + resp, err := c.retrier.Run( + client.Do, + req, + params.ErrorDecoder, + retryOptions..., + ) + if err != nil { + return nil, err + } + + // Close the response body after we're done. + defer resp.Body.Close() + + // 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, params.ErrorDecoder) + } + + // Mutate the response parameter in-place. + if params.Response != nil { + if writer, ok := params.Response.(io.Writer); ok { + _, err = io.Copy(writer, resp.Body) + } else { + err = json.NewDecoder(resp.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) { + requestBody, err := newRequestBody(request, bodyProperties) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, requestBody) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header.Set(contentTypeHeader, contentType) + 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{}) (io.Reader, error) { + if isNil(request) { + if len(bodyProperties) == 0 { + return nil, 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 + } + requestBytes, err := MarshalJSONWithExtraProperties(request, bodyProperties) + if err != nil { + return nil, err + } + return bytes.NewReader(requestBytes), nil +} + +// decodeError decodes the error from the given HTTP response. Note that +// it's the caller's responsibility to close the response body. +func decodeError(response *http.Response, 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, response.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(response.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 { + return value == nil || reflect.ValueOf(value).IsNil() +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/internal/caller_test.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/caller_test.go new file mode 100644 index 00000000000..fc5e597cc18 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/caller_test.go @@ -0,0 +1,395 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/imdb/fern/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCase represents a single test case. +type TestCase struct { + description string + + // Server-side assertions. + givePathSuffix string + giveMethod string + giveResponseIsOptional bool + giveHeader http.Header + giveErrorDecoder ErrorDecoder + giveRequest *Request + giveQueryParams url.Values + giveBodyProperties map[string]interface{} + + // Client-side assertions. + wantResponse *Response + wantHeaders http.Header + wantError error +} + +// Request a simple request body. +type Request struct { + Id string `json:"id"` +} + +// Response a simple response body. +type Response struct { + Id string `json:"id"` + ExtraBodyProperties map[string]interface{} `json:"extraBodyProperties,omitempty"` + QueryParameters url.Values `json:"queryParameters,omitempty"` +} + +// NotFoundError represents a 404. +type NotFoundError struct { + *core.APIError + + Message string `json:"message"` +} + +func TestCall(t *testing.T) { + tests := []*TestCase{ + { + description: "GET success", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &Request{ + Id: "123", + }, + wantResponse: &Response{ + Id: "123", + }, + }, + { + description: "GET success with query", + givePathSuffix: "?limit=1", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"success"}, + }, + giveRequest: &Request{ + Id: "123", + }, + wantResponse: &Response{ + Id: "123", + QueryParameters: url.Values{ + "limit": []string{"1"}, + }, + }, + }, + { + description: "GET not found", + giveMethod: http.MethodGet, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: &Request{ + Id: strconv.Itoa(http.StatusNotFound), + }, + giveErrorDecoder: newTestErrorDecoder(t), + wantError: &NotFoundError{ + 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: &Request{ + Id: "123", + }, + giveResponseIsOptional: true, + }, + { + description: "POST API error", + giveMethod: http.MethodPost, + giveHeader: http.Header{ + "X-API-Status": []string{"fail"}, + }, + giveRequest: &Request{ + 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(Request), + giveBodyProperties: map[string]interface{}{ + "key": "value", + }, + wantResponse: &Response{ + 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: &Request{ + Id: "123", + }, + wantResponse: &Response{ + 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: &Request{ + Id: "123", + }, + giveQueryParams: url.Values{ + "extra": []string{"true"}, + }, + wantResponse: &Response{ + 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 *Response + _, 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 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 *TestCase) *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(Request) + + 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 := &NotFoundError{ + 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 := &Response{ + 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) + }, + ), + ) +} + +// 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(NotFoundError) + value.APIError = apiError + require.NoError(t, decoder.Decode(value)) + + return value + } + return apiError + } +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder.go new file mode 100644 index 00000000000..c22bbf0b399 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder.go @@ -0,0 +1,47 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/imdb/fern/core" +) + +// 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 + +// ErrorCodes maps HTTP status codes to error constructors. +type ErrorCodes map[int]func(*core.APIError) error + +// NewErrorDecoder returns a new ErrorDecoder backed by the given error codes. +func NewErrorDecoder(errorCodes ErrorCodes) ErrorDecoder { + 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 := errorCodes[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/imdb/with-wiremock-tests/internal/error_decoder_test.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder_test.go new file mode 100644 index 00000000000..6b5265c92f9 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/error_decoder_test.go @@ -0,0 +1,59 @@ +package internal + +import ( + "bytes" + "errors" + "net/http" + "testing" + + "github.com/imdb/fern/core" + "github.com/stretchr/testify/assert" +) + +func TestErrorDecoder(t *testing.T) { + decoder := NewErrorDecoder( + ErrorCodes{ + http.StatusNotFound: func(apiError *core.APIError) error { + return &NotFoundError{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: &NotFoundError{ + 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/imdb/with-wiremock-tests/internal/extra_properties.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/extra_properties.go new file mode 100644 index 00000000000..540c3fd89ee --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/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/imdb/with-wiremock-tests/internal/extra_properties_test.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/extra_properties_test.go new file mode 100644 index 00000000000..aa2510ee512 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/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/imdb/with-wiremock-tests/internal/http.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/http.go new file mode 100644 index 00000000000..768968bd621 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/http.go @@ -0,0 +1,48 @@ +package internal + +import ( + "fmt" + "net/http" + "net/url" +) + +// 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. +func EncodeURL(urlFormat string, args ...interface{}) string { + escapedArgs := make([]interface{}, 0, len(args)) + for _, arg := range args { + escapedArgs = append(escapedArgs, url.PathEscape(fmt.Sprintf("%v", arg))) + } + return fmt.Sprintf(urlFormat, escapedArgs...) +} + +// 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/imdb/with-wiremock-tests/internal/query.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/query.go new file mode 100644 index 00000000000..786318b5c9c --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/query.go @@ -0,0 +1,350 @@ +package internal + +import ( + "encoding/base64" + "fmt" + "net/url" + "reflect" + "strings" + "time" + + "github.com/google/uuid" +) + +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 +} + +// 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 + } + + // 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(time.RFC3339) + } + + 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/imdb/with-wiremock-tests/internal/query_test.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/query_test.go new file mode 100644 index 00000000000..60d4be9e3ec --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/query_test.go @@ -0,0 +1,374 @@ +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%3A56Z", 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()) +}) +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/internal/retrier.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/retrier.go new file mode 100644 index 00000000000..bb8e954a794 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/retrier.go @@ -0,0 +1,213 @@ +package internal + +import ( + "crypto/rand" + "math/big" + "net/http" + "strconv" + "time" +) + +const ( + defaultRetryAttempts = 2 + minRetryDelay = 500 * time.Millisecond + maxRetryDelay = 5000 * 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 + } +} + +// 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 + } + 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 + } + + response, err := fn(request) + if err != nil { + return nil, err + } + + if r.shouldRetry(response) { + defer response.Body.Close() + + delay, err := r.retryDelay(response, retryAttempt) + if err != nil { + return nil, err + } + + time.Sleep(delay) + + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt + 1, + decodeError(response, 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) + 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 > maxRetryDelay { + delay = maxRetryDelay + } + return r.addJitter(delay) + } + + // ...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 { + delay = 0 + } + if delay > maxRetryDelay { + delay = maxRetryDelay + } + return r.addJitter(delay) + } + } + + // Then check for industry-standard X-RateLimit-Reset header + 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.addJitter(delay) + } + } + } + + // Fall back to exponential backoff + return r.exponentialBackoffDelay(retryAttempt) +} + +// exponentialBackoffDelay calculates the delay time in milliseconds based on the retry attempt. +func (r *Retrier) exponentialBackoffDelay(retryAttempt uint) (time.Duration, error) { + // Apply exponential backoff. + delay := minRetryDelay + minRetryDelay * time.Duration(retryAttempt * retryAttempt) + if delay > maxRetryDelay { + delay = maxRetryDelay + } + + return r.addJitter(delay) +} + +// addJitter applies jitter to the given delay by randomizing the value +// in the range of 75%-100%. +func (r *Retrier) addJitter(delay time.Duration) (time.Duration, error) { + max := big.NewInt(int64(delay / 4)) + jitter, err := rand.Int(rand.Reader, max) + if err != nil { + return 0, err + } + + delay -= time.Duration(jitter.Int64()) + if delay < minRetryDelay { + delay = minRetryDelay + } + + return delay, nil +} + +type retryOptions struct { + attempts uint +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/internal/retrier_test.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/retrier_test.go new file mode 100644 index 00000000000..965602d4431 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/retrier_test.go @@ -0,0 +1,310 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/imdb/fern/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type RetryTestCase struct { + description string + + giveAttempts uint + giveStatusCodes []int + giveResponse *Response + + wantResponse *Response + 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: &Response{ + Id: "1", + }, + wantResponse: &Response{ + 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 *Response + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Request: &Request{}, + 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) + }) + } +} + +// 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(Request) + 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 + minRetryDelay*i*i`, with +// a max and min value of 5000ms and 500ms respectively. +var expectedRetryDurations = []time.Duration{ + 500 * time.Millisecond, + 1000 * time.Millisecond, + 2500 * time.Millisecond, + 5000 * time.Millisecond, + 5000 * time.Millisecond, +} + +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: 700, // 70% of 1000ms after jitter and execution overhead + expectedMaxMs: 1100, // 1000ms + some tolerance + }, + { + name: "retry-after with HTTP date", + headerName: "retry-after", + headerValueFunc: func() string { + return time.Now().Add(3 * time.Second).Format(time.RFC1123) + }, + expectedMinMs: 1500, // 50% of 3000ms after jitter and execution overhead + expectedMaxMs: 3100, // 3000ms + some tolerance + }, + { + 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, // 50% of 3000ms after jitter and execution overhead + expectedMaxMs: 3100, // 3000ms + some tolerance + }, + { + name: "retry-after exceeding max delay gets capped", + headerName: "retry-after", + headerValueFunc: func() string { + return "10" + }, + expectedMinMs: 2500, // 50% of 5000ms (maxRetryDelay) after jitter and execution overhead + expectedMaxMs: 5100, // maxRetryDelay + some tolerance + }, + } + + for _, tt := range tests { + 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 := &Response{Id: "success"} + bytes, _ := json.Marshal(response) + w.Write(bytes) + } + })) + defer server.Close() + + caller := NewCaller(&CallerParams{ + Client: server.Client(), + }) + + var response *Response + _, err := caller.Call( + context.Background(), + &CallParams{ + URL: server.URL, + Method: http.MethodGet, + Request: &Request{}, + 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/imdb/with-wiremock-tests/internal/stringer.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/stringer.go new file mode 100644 index 00000000000..312801851e0 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/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/imdb/with-wiremock-tests/internal/time.go b/seed/go-sdk/imdb/with-wiremock-tests/internal/time.go new file mode 100644 index 00000000000..ab0e269fade --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/internal/time.go @@ -0,0 +1,137 @@ +package internal + +import ( + "encoding/json" + "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 +} + +// 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 { + return err + } + + parsedTime, err := time.Parse(time.RFC3339, raw) + if err != nil { + return err + } + + *d = DateTime{t: &parsedTime} + return nil +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/option/request_option.go b/seed/go-sdk/imdb/with-wiremock-tests/option/request_option.go new file mode 100644 index 00000000000..6ea083ff1be --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/option/request_option.go @@ -0,0 +1,71 @@ +// Code generated by Fern. DO NOT EDIT. + +package option + +import ( + core "github.com/imdb/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, + } +} + +// WithToken sets the 'Authorization: Bearer ' request header. +func WithToken(token string) *core.TokenOption { + return &core.TokenOption{ + Token: token, + } +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/pointer.go b/seed/go-sdk/imdb/with-wiremock-tests/pointer.go new file mode 100644 index 00000000000..faaf462e6d0 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/pointer.go @@ -0,0 +1,132 @@ +package api + +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 +} + +// 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/imdb/with-wiremock-tests/reference.md b/seed/go-sdk/imdb/with-wiremock-tests/reference.md new file mode 100644 index 00000000000..e5ef726ff56 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/reference.md @@ -0,0 +1,106 @@ +# Reference +## Imdb +
client.Imdb.CreateMovie(request) -> fern.MovieId +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Add a movie to the database using the movies/* /... path. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +client.Imdb.CreateMovie( + context.TODO(), + &fern.CreateMovieRequest{ + Title: "title", + Rating: 1.1, + }, + ) +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `*fern.CreateMovieRequest` + +
+
+
+
+ + +
+
+
+ +
client.Imdb.GetMovie(MovieId) -> *fern.Movie +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```go +client.Imdb.GetMovie( + context.TODO(), + "movieId", + ) +} +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**movieId:** `fern.MovieId` + +
+
+
+
+ + +
+
+
diff --git a/seed/go-sdk/imdb/with-wiremock-tests/snippet-templates.json b/seed/go-sdk/imdb/with-wiremock-tests/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/go-sdk/imdb/with-wiremock-tests/snippet.json b/seed/go-sdk/imdb/with-wiremock-tests/snippet.json new file mode 100644 index 00000000000..7b770a7ba96 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/snippet.json @@ -0,0 +1,26 @@ +{ + "endpoints": [ + { + "id": { + "path": "/movies/create-movie", + "method": "POST", + "identifier_override": "endpoint_imdb.createMovie" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfern \"github.com/imdb/fern\"\n\tfernclient \"github.com/imdb/fern/client\"\n\toption \"github.com/imdb/fern/option\"\n)\n\nclient := fernclient.NewClient(\n\toption.WithToken(\n\t\t\"\u003cYOUR_AUTH_TOKEN\u003e\",\n\t),\n)\nresponse, err := client.Imdb.CreateMovie(\n\tcontext.TODO(),\n\t\u0026fern.CreateMovieRequest{\n\t\tTitle: \"title\",\n\t\tRating: 1.1,\n\t},\n)\n" + } + }, + { + "id": { + "path": "/movies/{movieId}", + "method": "GET", + "identifier_override": "endpoint_imdb.getMovie" + }, + "snippet": { + "type": "go", + "client": "import (\n\tcontext \"context\"\n\tfernclient \"github.com/imdb/fern/client\"\n\toption \"github.com/imdb/fern/option\"\n)\n\nclient := fernclient.NewClient(\n\toption.WithToken(\n\t\t\"\u003cYOUR_AUTH_TOKEN\u003e\",\n\t),\n)\nresponse, err := client.Imdb.GetMovie(\n\tcontext.TODO(),\n\t\"movieId\",\n)\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/go-sdk/imdb/with-wiremock-tests/test/imdb_test.go b/seed/go-sdk/imdb/with-wiremock-tests/test/imdb_test.go new file mode 100644 index 00000000000..f0afd785f04 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/test/imdb_test.go @@ -0,0 +1,72 @@ +package wiremock + +import ( + "context" + "net/http" + "testing" + fern "github.com/imdb/fern" + client "github.com/imdb/fern/client" + option "github.com/imdb/fern/option" + require "github.com/stretchr/testify/require" + gowiremock "github.com/wiremock/go-wiremock" +) +func TestImdbCreateMovieWithWireMock( + t *testing.T, +) { + // wiremock client and server initialized in shared main_test.go + defer WireMockClient.Reset() + stub := gowiremock.Post(gowiremock.URLPathTemplate("/movies/create-movie")).WillReturnResponse( + gowiremock.NewResponse().WithJSONBody( + map[string]interface{}{}, + ).WithStatus(http.StatusOK), + ) + err := WireMockClient.StubFor(stub) + require.NoError(t, err, "Failed to create WireMock stub") + + client := client.NewClient( + option.WithBaseURL( + WireMockBaseURL, + ), + ) + client.Imdb.CreateMovie( + context.TODO(), + &fern.CreateMovieRequest{ + Title: "title", + Rating: 1.1, + }, + ) + + ok, countErr := WireMockClient.Verify(stub.Request(), 1) + require.NoError(t, countErr, "Failed to verify WireMock request was matched") + require.True(t, ok, "WireMock request was not matched") +} +func TestImdbGetMovieWithWireMock( + t *testing.T, +) { + // wiremock client and server initialized in shared main_test.go + defer WireMockClient.Reset() + stub := gowiremock.Get(gowiremock.URLPathTemplate("/movies/{movieId}")).WithPathParam( + "movieId", + gowiremock.Matching(".+"), + ).WillReturnResponse( + gowiremock.NewResponse().WithJSONBody( + map[string]interface{}{}, + ).WithStatus(http.StatusOK), + ) + err := WireMockClient.StubFor(stub) + require.NoError(t, err, "Failed to create WireMock stub") + + client := client.NewClient( + option.WithBaseURL( + WireMockBaseURL, + ), + ) + client.Imdb.GetMovie( + context.TODO(), + "movieId", + ) + + ok, countErr := WireMockClient.Verify(stub.Request(), 1) + require.NoError(t, countErr, "Failed to verify WireMock request was matched") + require.True(t, ok, "WireMock request was not matched") +} diff --git a/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go b/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go new file mode 100644 index 00000000000..9d693879398 --- /dev/null +++ b/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go @@ -0,0 +1,57 @@ +package wiremock + +import ( + "context" + "fmt" + "os" + "testing" + + gowiremock "github.com/wiremock/go-wiremock" + wiremocktestcontainersgo "github.com/wiremock/wiremock-testcontainers-go" +) + +// Global test fixtures +var ( + WireMockContainer *wiremocktestcontainersgo.WireMockContainer + WireMockBaseURL string + WireMockClient *gowiremock.Client +) + +// TestMain sets up shared test fixtures for all tests in this package +func TestMain(m *testing.M) { + // Setup shared WireMock container + ctx := context.Background() + container, err := wiremocktestcontainersgo.RunContainerAndStopOnCleanup( + ctx, + &testing.T{}, // We need a testing.T for the cleanup function, but we'll handle cleanup manually + wiremocktestcontainersgo.WithImage("docker.io/wiremock/wiremock:3.9.1"), + ) + if err != nil { + fmt.Printf("Failed to start WireMock container: %v\n", err) + os.Exit(1) + } + + // Store global references + WireMockContainer = container + WireMockClient = container.Client + + // Get the base URL + baseURL, err := container.Endpoint(ctx, "") + if err != nil { + fmt.Printf("Failed to get WireMock container endpoint: %v\n", err) + os.Exit(1) + } + WireMockBaseURL = "http://" + baseURL + + // Run all tests + code := m.Run() + + // This step is most likely unnecessary + // Cleanup + //if WireMockContainer != nil { + // WireMockContainer.Terminate(ctx) + //} + + // Exit with the same code as the tests + os.Exit(code) +} From 7bf63142a2bdac653028fdf0dc0f4849c5d4fb7e Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 13:58:38 -0400 Subject: [PATCH 11/18] remove uneeded initializer flag --- generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts b/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts index 97770aa89a1..5704a3a6987 100644 --- a/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts +++ b/generators/go-v2/sdk/src/reference/EndpointSnippetsGenerator.ts @@ -21,11 +21,9 @@ export class EndpointSnippetsGenerator { private readonly context: SdkGeneratorContext; private readonly snippetsCache: Map = new Map(); - private snippetsCacheInitialized: boolean = false; constructor({ context }: { context: SdkGeneratorContext }) { this.context = context; - this.snippetsCacheInitialized = false; } public async populateSnippetsCache(): Promise { From 3ae5f9a685def20bd7e4e2383275c137fbaa052d Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 13:59:58 -0400 Subject: [PATCH 12/18] use consistent property declaration --- generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts b/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts index 41efc51f0aa..a4abf9c0783 100644 --- a/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts +++ b/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts @@ -8,10 +8,11 @@ import { convertDynamicEndpointSnippetRequest } from "../utils/convertEndpointSn import { convertIr } from "../utils/convertIr"; export class WireTestGenerator { + private readonly context: SdkGeneratorContext; private dynamicIr: dynamic.DynamicIntermediateRepresentation; private dynamicSnippetsGenerator: DynamicSnippetsGenerator; - constructor(private readonly context: SdkGeneratorContext) { + constructor(context: SdkGeneratorContext) { this.context = context; const dynamicIr = this.context.ir.dynamic; if (!dynamicIr) { From 0245f97fa5ac7ab69b9108ae4be6e21332d1ed2e Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 14:09:14 -0400 Subject: [PATCH 13/18] fix biome --- generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts b/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts index a4abf9c0783..b8beea8eaee 100644 --- a/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts +++ b/generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts @@ -1,4 +1,4 @@ -import { File, Style } from "@fern-api/base-generator"; +import { File } from "@fern-api/base-generator"; import { RelativeFilePath } from "@fern-api/fs-utils"; import { go } from "@fern-api/go-ast"; import { DynamicSnippetsGenerator } from "@fern-api/go-dynamic-snippets"; @@ -177,7 +177,7 @@ export class WireTestGenerator { return [testMethodContent, imports]; } - private writeImports(writer: any, imports: Map): void { + private writeImports(writer: go.Writer, imports: Map): void { const standardLibraryImports: string[] = []; const externalImports: Array<[string, string]> = []; From 6c100b60cb26c8541dc309cae89225d8781f2c76 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 14:18:17 -0400 Subject: [PATCH 14/18] fix test snapshots to use new aliasing behavior --- .../__test__/__snapshots__/Snippets.test.ts.snap | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap b/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap index 58a724d41fe..a5860800a60 100644 --- a/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap +++ b/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap @@ -54,7 +54,7 @@ exports[`Snippets > 'enum (optional)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" ) var value = acme.StatusDeactivated.Ptr()" @@ -64,7 +64,7 @@ exports[`Snippets > 'enum (required)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" ) var value = acme.StatusActivated" @@ -80,7 +80,7 @@ exports[`Snippets > 'map (nested)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" ) var value = map[string]acme.User{ @@ -151,7 +151,7 @@ exports[`Snippets > 'struct (empty)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" ) var value = acme.User{}" @@ -161,7 +161,7 @@ exports[`Snippets > 'struct (nested)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" billing "github.com/acme/acme-go/billing" uuid "github.com/google/uuid" ) @@ -186,7 +186,7 @@ exports[`Snippets > 'struct (primitives)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" ) var value = acme.User{ @@ -202,7 +202,7 @@ exports[`Snippets > 'struct (w/ nop)' 1`] = ` "package example import ( - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" ) var value = acme.User{ From fd49ee906b4ef3d5bff3a8487007736a7de4986c Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 14:22:03 -0400 Subject: [PATCH 15/18] fully fix snapshots --- .../__snapshots__/Snippets.test.ts.snap | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap b/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap index a5860800a60..d981836da0c 100644 --- a/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap +++ b/generators/go-v2/ast/src/ast/__test__/__snapshots__/Snippets.test.ts.snap @@ -57,7 +57,7 @@ import ( acmego "github.com/acme/acme-go" ) -var value = acme.StatusDeactivated.Ptr()" +var value = acmego.StatusDeactivated.Ptr()" `; exports[`Snippets > 'enum (required)' 1`] = ` @@ -67,7 +67,7 @@ import ( acmego "github.com/acme/acme-go" ) -var value = acme.StatusActivated" +var value = acmego.StatusActivated" `; exports[`Snippets > 'map (empty)' 1`] = ` @@ -83,11 +83,11 @@ import ( acmego "github.com/acme/acme-go" ) -var value = map[string]acme.User{ - "john": acme.User{ +var value = map[string]acmego.User{ + "john": acmego.User{ Name: "John Doe", }, - "jane": acme.User{ + "jane": acmego.User{ Name: "Jane Doe", }, }" @@ -154,7 +154,7 @@ import ( acmego "github.com/acme/acme-go" ) -var value = acme.User{}" +var value = acmego.User{}" `; exports[`Snippets > 'struct (nested)' 1`] = ` @@ -166,15 +166,15 @@ import ( uuid "github.com/google/uuid" ) -var value = acme.User{ +var value = acmego.User{ Name: "John Doe", Address: billing.Address{ ID: uuid.MustParse( "123e4567-e89b-12d3-a456-426614174000", ), Street: "123 Main St.", - CreatedAt: acme.Time( - acme.MustParseDateTime( + CreatedAt: acmego.Time( + acmego.MustParseDateTime( "1994-01-01T00:00:00Z", ), ), @@ -189,10 +189,10 @@ import ( acmego "github.com/acme/acme-go" ) -var value = acme.User{ +var value = acmego.User{ Name: "John Doe", Age: 42, - Active: acme.Bool( + Active: acmego.Bool( true, ), }" @@ -205,7 +205,7 @@ import ( acmego "github.com/acme/acme-go" ) -var value = acme.User{ +var value = acmego.User{ Name: "John Doe", Age: 42, }" From 7604ad454e3e2e33d6e2895a54da91411836c752 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 14:27:37 -0400 Subject: [PATCH 16/18] fix dynamic snippet snapshot tests also --- .../DynamicSnippetsGenerator.test.ts.snap | 272 +++++++++--------- 1 file changed, 136 insertions(+), 136 deletions(-) diff --git a/generators/go-v2/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap b/generators/go-v2/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap index 39141abafa5..e97ff422b25 100644 --- a/generators/go-v2/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap +++ b/generators/go-v2/dynamic-snippets/src/__test__/__snapshots__/DynamicSnippetsGenerator.test.ts.snap @@ -5,7 +5,7 @@ exports[`snippets (default) > examples > 'GET /metadata (allow-multiple)' 1`] = import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -18,15 +18,15 @@ func do() { ) client.Service.GetMetadata( context.TODO(), - &acme.GetMetadataRequest{ - Shallow: acme.Bool( + &acmego.GetMetadataRequest{ + Shallow: acmego.Bool( false, ), Tag: []*string{ - acme.String( + acmego.String( "development", ), - acme.String( + acmego.String( "public", ), }, @@ -42,7 +42,7 @@ exports[`snippets (default) > examples > 'GET /metadata (simple)' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -55,12 +55,12 @@ func do() { ) client.Service.GetMetadata( context.TODO(), - &acme.GetMetadataRequest{ - Shallow: acme.Bool( + &acmego.GetMetadataRequest{ + Shallow: acmego.Bool( false, ), Tag: []*string{ - acme.String( + acmego.String( "development", ), }, @@ -76,7 +76,7 @@ exports[`snippets (default) > examples > 'POST /big-entity (simple)' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" commons "github.com/acme/acme-go/commons" option "github.com/acme/acme-go/option" @@ -90,14 +90,14 @@ func do() { ) client.Service.CreateBigEntity( context.TODO(), - &acme.BigEntity{ - CastMember: &acme.CastMember{ - Actor: &acme.Actor{ + &acmego.BigEntity{ + CastMember: &acmego.CastMember{ + Actor: &acmego.Actor{ ID: "john.doe", Name: "John Doe", }, }, - ExtendedMovie: &acme.ExtendedMovie{ + ExtendedMovie: &acmego.ExtendedMovie{ Cast: []string{ "John Travolta", "Samuel L. Jackson", @@ -126,14 +126,14 @@ func do() { "key1": "val1", "key2": "val2", }, - JSONString: acme.String( + JSONString: acmego.String( "abc", ), }, }, - Migration: &acme.Migration{ + Migration: &acmego.Migration{ Name: "Migration 31 Aug", - Status: acme.MigrationStatusRunning, + Status: acmego.MigrationStatusRunning, }, }, ) @@ -159,7 +159,7 @@ exports[`snippets (default) > examples > 'POST /movie (simple)' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -172,9 +172,9 @@ func do() { ) client.Service.CreateMovie( context.TODO(), - &acme.Movie{ + &acmego.Movie{ ID: "movie-c06a4ad7", - Prequel: acme.String( + Prequel: acmego.String( "movie-cv9b914f", ), Title: "The Boy and the Heron", @@ -205,7 +205,7 @@ exports[`snippets (default) > exhaustive > 'GET /object/get-and-return-with-opti import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" types "github.com/acme/acme-go/types" @@ -221,32 +221,32 @@ func do() { client.Endpoints.Object.GetAndReturnWithOptionalField( context.TODO(), &types.ObjectWithOptionalField{ - String: acme.String( + String: acmego.String( "string", ), - Integer: acme.Int( + Integer: acmego.Int( 1, ), - Long: acme.Int64( + Long: acmego.Int64( 1000000, ), - Double: acme.Float64( + Double: acmego.Float64( 1.1, ), - Bool: acme.Bool( + Bool: acmego.Bool( true, ), - Datetime: acme.Time( - acme.MustParseDateTime( + Datetime: acmego.Time( + acmego.MustParseDateTime( "2024-01-15T09:30:00Z", ), ), - Date: acme.Time( - acme.MustParseDateTime( + Date: acmego.Time( + acmego.MustParseDateTime( "2023-01-15", ), ), - UUID: acme.UUID( + UUID: acmego.UUID( uuid.MustParse( "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32", ), @@ -262,7 +262,7 @@ func do() { Map: map[int]string{ 1: "map", }, - Bigint: acme.String( + Bigint: acmego.String( "1000000", ), }, @@ -366,7 +366,7 @@ exports[`snippets (default) > file-upload > 'POST /' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" io "io" strings "strings" @@ -376,7 +376,7 @@ func do() { client := client.NewClient() client.Service.Post( context.TODO(), - &acme.MyRequest{ + &acmego.MyRequest{ File: strings.NewReader( "Hello, world!", ), @@ -399,7 +399,7 @@ exports[`snippets (default) > file-upload > 'POST /just-file' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" strings "strings" ) @@ -408,7 +408,7 @@ func do() { client := client.NewClient() client.Service.JustFile( context.TODO(), - &acme.JustFileRequest{ + &acmego.JustFileRequest{ File: strings.NewReader( "Hello, world!", ), @@ -423,7 +423,7 @@ exports[`snippets (default) > file-upload > 'POST /just-file-with-query-params' import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" strings "strings" ) @@ -432,9 +432,9 @@ func do() { client := client.NewClient() client.Service.JustFileWithQueryParams( context.TODO(), - &acme.JustFileWithQueryParamsRequest{ + &acmego.JustFileWithQueryParamsRequest{ Integer: 42, - MaybeString: acme.String( + MaybeString: acmego.String( "exists", ), File: strings.NewReader( @@ -474,7 +474,7 @@ exports[`snippets (default) > imdb > 'POST /movies/create-movie (simple)' 1`] = import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -487,7 +487,7 @@ func do() { ) client.Imdb.CreateMovie( context.TODO(), - &acme.CreateMovieRequest{ + &acmego.CreateMovieRequest{ Title: "The Matrix", Rating: 8.2, }, @@ -521,7 +521,7 @@ exports[`snippets (default) > multi-url-environment > 'Production environment' 1 import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -529,7 +529,7 @@ import ( func do() { client := client.NewClient( option.WithBaseURL( - acme.Environments.Production, + acmego.Environments.Production, ), option.WithToken( "", @@ -537,7 +537,7 @@ func do() { ) client.S3.GetPresignedURL( context.TODO(), - &acme.GetPresignedURLRequest{ + &acmego.GetPresignedURLRequest{ S3Key: "xyz", }, ) @@ -550,7 +550,7 @@ exports[`snippets (default) > multi-url-environment > 'Staging environment' 1`] import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -558,7 +558,7 @@ import ( func do() { client := client.NewClient( option.WithBaseURL( - acme.Environments.Staging, + acmego.Environments.Staging, ), option.WithToken( "", @@ -566,7 +566,7 @@ func do() { ) client.S3.GetPresignedURL( context.TODO(), - &acme.GetPresignedURLRequest{ + &acmego.GetPresignedURLRequest{ S3Key: "xyz", }, ) @@ -589,7 +589,7 @@ exports[`snippets (default) > nullable > 'Body properties' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -602,16 +602,16 @@ func do() { ) client.Nullable.CreateUser( context.TODO(), - &acme.CreateUserRequest{ + &acmego.CreateUserRequest{ Username: "john.doe", Tags: []string{ "admin", }, - Metadata: &acme.Metadata{ - CreatedAt: acme.MustParseDateTime( + Metadata: &acmego.Metadata{ + CreatedAt: acmego.MustParseDateTime( "1980-01-01T00:00:00Z", ), - UpdatedAt: acme.MustParseDateTime( + UpdatedAt: acmego.MustParseDateTime( "1980-01-01T00:00:00Z", ), }, @@ -638,7 +638,7 @@ exports[`snippets (default) > nullable > 'Query parameters' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -651,12 +651,12 @@ func do() { ) client.Nullable.GetUsers( context.TODO(), - &acme.GetUsersRequest{ + &acmego.GetUsersRequest{ Usernames: []*string{ - acme.String( + acmego.String( "john.doe", ), - acme.String( + acmego.String( "jane.doe", ), }, @@ -672,7 +672,7 @@ exports[`snippets (default) > read-write-only > 'Body properties' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -685,11 +685,11 @@ func do() { ) client.CreateUser( context.TODO(), - &acme.User{ + &acmego.User{ Password: "password", - Profile: &acme.UserProfile{ + Profile: &acmego.UserProfile{ Name: "name", - Verification: &acme.UserProfileVerification{}, + Verification: &acmego.UserProfileVerification{}, Ssn: "ssn", }, }, @@ -703,7 +703,7 @@ exports[`snippets (default) > read-write-only > 'Readonly value in request' 1`] import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -716,12 +716,12 @@ func do() { ) client.CreateUser( context.TODO(), - &acme.User{ + &acmego.User{ ID: "id", Password: "password", - Profile: &acme.UserProfile{ + Profile: &acmego.UserProfile{ Name: "name", - Verification: &acme.UserProfileVerification{}, + Verification: &acmego.UserProfileVerification{}, Ssn: "ssn", }, }, @@ -735,7 +735,7 @@ exports[`snippets (default) > single-url-environment-default > 'Production envir import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -743,7 +743,7 @@ import ( func do() { client := client.NewClient( option.WithBaseURL( - acme.Environments.Production, + acmego.Environments.Production, ), option.WithToken( "", @@ -761,7 +761,7 @@ exports[`snippets (default) > single-url-environment-default > 'Staging environm import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -769,7 +769,7 @@ import ( func do() { client := client.NewClient( option.WithBaseURL( - acme.Environments.Staging, + acmego.Environments.Staging, ), option.WithToken( "", @@ -832,7 +832,7 @@ exports[`snippets (exportedClientName) > examples > 'GET /metadata (allow-multip import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -845,15 +845,15 @@ func do() { ) client.Service.GetMetadata( context.TODO(), - &acme.GetMetadataRequest{ - Shallow: acme.Bool( + &acmego.GetMetadataRequest{ + Shallow: acmego.Bool( false, ), Tag: []*string{ - acme.String( + acmego.String( "development", ), - acme.String( + acmego.String( "public", ), }, @@ -869,7 +869,7 @@ exports[`snippets (exportedClientName) > examples > 'GET /metadata (simple)' 1`] import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -882,12 +882,12 @@ func do() { ) client.Service.GetMetadata( context.TODO(), - &acme.GetMetadataRequest{ - Shallow: acme.Bool( + &acmego.GetMetadataRequest{ + Shallow: acmego.Bool( false, ), Tag: []*string{ - acme.String( + acmego.String( "development", ), }, @@ -903,7 +903,7 @@ exports[`snippets (exportedClientName) > examples > 'POST /big-entity (simple)' import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" commons "github.com/acme/acme-go/commons" option "github.com/acme/acme-go/option" @@ -917,14 +917,14 @@ func do() { ) client.Service.CreateBigEntity( context.TODO(), - &acme.BigEntity{ - CastMember: &acme.CastMember{ - Actor: &acme.Actor{ + &acmego.BigEntity{ + CastMember: &acmego.CastMember{ + Actor: &acmego.Actor{ ID: "john.doe", Name: "John Doe", }, }, - ExtendedMovie: &acme.ExtendedMovie{ + ExtendedMovie: &acmego.ExtendedMovie{ Cast: []string{ "John Travolta", "Samuel L. Jackson", @@ -953,14 +953,14 @@ func do() { "key1": "val1", "key2": "val2", }, - JSONString: acme.String( + JSONString: acmego.String( "abc", ), }, }, - Migration: &acme.Migration{ + Migration: &acmego.Migration{ Name: "Migration 31 Aug", - Status: acme.MigrationStatusRunning, + Status: acmego.MigrationStatusRunning, }, }, ) @@ -986,7 +986,7 @@ exports[`snippets (exportedClientName) > examples > 'POST /movie (simple)' 1`] = import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -999,9 +999,9 @@ func do() { ) client.Service.CreateMovie( context.TODO(), - &acme.Movie{ + &acmego.Movie{ ID: "movie-c06a4ad7", - Prequel: acme.String( + Prequel: acmego.String( "movie-cv9b914f", ), Title: "The Boy and the Heron", @@ -1032,7 +1032,7 @@ exports[`snippets (exportedClientName) > exhaustive > 'GET /object/get-and-retur import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" types "github.com/acme/acme-go/types" @@ -1048,32 +1048,32 @@ func do() { client.Endpoints.Object.GetAndReturnWithOptionalField( context.TODO(), &types.ObjectWithOptionalField{ - String: acme.String( + String: acmego.String( "string", ), - Integer: acme.Int( + Integer: acmego.Int( 1, ), - Long: acme.Int64( + Long: acmego.Int64( 1000000, ), - Double: acme.Float64( + Double: acmego.Float64( 1.1, ), - Bool: acme.Bool( + Bool: acmego.Bool( true, ), - Datetime: acme.Time( - acme.MustParseDateTime( + Datetime: acmego.Time( + acmego.MustParseDateTime( "2024-01-15T09:30:00Z", ), ), - Date: acme.Time( - acme.MustParseDateTime( + Date: acmego.Time( + acmego.MustParseDateTime( "2023-01-15", ), ), - UUID: acme.UUID( + UUID: acmego.UUID( uuid.MustParse( "d5e9c84f-c2b2-4bf4-b4b0-7ffd7a9ffc32", ), @@ -1089,7 +1089,7 @@ func do() { Map: map[int]string{ 1: "map", }, - Bigint: acme.String( + Bigint: acmego.String( "1000000", ), }, @@ -1193,7 +1193,7 @@ exports[`snippets (exportedClientName) > file-upload > 'POST /' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" io "io" strings "strings" @@ -1203,7 +1203,7 @@ func do() { client := client.NewFernClient() client.Service.Post( context.TODO(), - &acme.MyRequest{ + &acmego.MyRequest{ File: strings.NewReader( "Hello, world!", ), @@ -1226,7 +1226,7 @@ exports[`snippets (exportedClientName) > file-upload > 'POST /just-file' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" strings "strings" ) @@ -1235,7 +1235,7 @@ func do() { client := client.NewFernClient() client.Service.JustFile( context.TODO(), - &acme.JustFileRequest{ + &acmego.JustFileRequest{ File: strings.NewReader( "Hello, world!", ), @@ -1250,7 +1250,7 @@ exports[`snippets (exportedClientName) > file-upload > 'POST /just-file-with-que import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" strings "strings" ) @@ -1259,9 +1259,9 @@ func do() { client := client.NewFernClient() client.Service.JustFileWithQueryParams( context.TODO(), - &acme.JustFileWithQueryParamsRequest{ + &acmego.JustFileWithQueryParamsRequest{ Integer: 42, - MaybeString: acme.String( + MaybeString: acmego.String( "exists", ), File: strings.NewReader( @@ -1301,7 +1301,7 @@ exports[`snippets (exportedClientName) > imdb > 'POST /movies/create-movie (simp import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1314,7 +1314,7 @@ func do() { ) client.Imdb.CreateMovie( context.TODO(), - &acme.CreateMovieRequest{ + &acmego.CreateMovieRequest{ Title: "The Matrix", Rating: 8.2, }, @@ -1348,7 +1348,7 @@ exports[`snippets (exportedClientName) > multi-url-environment > 'Production env import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1356,7 +1356,7 @@ import ( func do() { client := client.NewFernClient( option.WithBaseURL( - acme.Environments.Production, + acmego.Environments.Production, ), option.WithToken( "", @@ -1364,7 +1364,7 @@ func do() { ) client.S3.GetPresignedURL( context.TODO(), - &acme.GetPresignedURLRequest{ + &acmego.GetPresignedURLRequest{ S3Key: "xyz", }, ) @@ -1377,7 +1377,7 @@ exports[`snippets (exportedClientName) > multi-url-environment > 'Staging enviro import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1385,7 +1385,7 @@ import ( func do() { client := client.NewFernClient( option.WithBaseURL( - acme.Environments.Staging, + acmego.Environments.Staging, ), option.WithToken( "", @@ -1393,7 +1393,7 @@ func do() { ) client.S3.GetPresignedURL( context.TODO(), - &acme.GetPresignedURLRequest{ + &acmego.GetPresignedURLRequest{ S3Key: "xyz", }, ) @@ -1416,7 +1416,7 @@ exports[`snippets (exportedClientName) > nullable > 'Body properties' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1429,16 +1429,16 @@ func do() { ) client.Nullable.CreateUser( context.TODO(), - &acme.CreateUserRequest{ + &acmego.CreateUserRequest{ Username: "john.doe", Tags: []string{ "admin", }, - Metadata: &acme.Metadata{ - CreatedAt: acme.MustParseDateTime( + Metadata: &acmego.Metadata{ + CreatedAt: acmego.MustParseDateTime( "1980-01-01T00:00:00Z", ), - UpdatedAt: acme.MustParseDateTime( + UpdatedAt: acmego.MustParseDateTime( "1980-01-01T00:00:00Z", ), }, @@ -1465,7 +1465,7 @@ exports[`snippets (exportedClientName) > nullable > 'Query parameters' 1`] = ` import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1478,12 +1478,12 @@ func do() { ) client.Nullable.GetUsers( context.TODO(), - &acme.GetUsersRequest{ + &acmego.GetUsersRequest{ Usernames: []*string{ - acme.String( + acmego.String( "john.doe", ), - acme.String( + acmego.String( "jane.doe", ), }, @@ -1499,7 +1499,7 @@ exports[`snippets (exportedClientName) > read-write-only > 'Body properties' 1`] import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1512,11 +1512,11 @@ func do() { ) client.CreateUser( context.TODO(), - &acme.User{ + &acmego.User{ Password: "password", - Profile: &acme.UserProfile{ + Profile: &acmego.UserProfile{ Name: "name", - Verification: &acme.UserProfileVerification{}, + Verification: &acmego.UserProfileVerification{}, Ssn: "ssn", }, }, @@ -1530,7 +1530,7 @@ exports[`snippets (exportedClientName) > read-write-only > 'Readonly value in re import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1543,12 +1543,12 @@ func do() { ) client.CreateUser( context.TODO(), - &acme.User{ + &acmego.User{ ID: "id", Password: "password", - Profile: &acme.UserProfile{ + Profile: &acmego.UserProfile{ Name: "name", - Verification: &acme.UserProfileVerification{}, + Verification: &acmego.UserProfileVerification{}, Ssn: "ssn", }, }, @@ -1562,7 +1562,7 @@ exports[`snippets (exportedClientName) > single-url-environment-default > 'Produ import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1570,7 +1570,7 @@ import ( func do() { client := client.NewFernClient( option.WithBaseURL( - acme.Environments.Production, + acmego.Environments.Production, ), option.WithToken( "", @@ -1588,7 +1588,7 @@ exports[`snippets (exportedClientName) > single-url-environment-default > 'Stagi import ( context "context" - acme "github.com/acme/acme-go" + acmego "github.com/acme/acme-go" client "github.com/acme/acme-go/client" option "github.com/acme/acme-go/option" ) @@ -1596,7 +1596,7 @@ import ( func do() { client := client.NewFernClient( option.WithBaseURL( - acme.Environments.Staging, + acmego.Environments.Staging, ), option.WithToken( "", From 105ae3bc107f570d61e12d65f8e3206f95cd3c92 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 15:30:54 -0400 Subject: [PATCH 17/18] small tweak to main_test --- generators/go-v2/base/src/asIs/test/main_test.go_ | 9 ++++----- seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/generators/go-v2/base/src/asIs/test/main_test.go_ b/generators/go-v2/base/src/asIs/test/main_test.go_ index 611220895cb..5dbf0c4bcb8 100644 --- a/generators/go-v2/base/src/asIs/test/main_test.go_ +++ b/generators/go-v2/base/src/asIs/test/main_test.go_ @@ -23,7 +23,7 @@ func TestMain(m *testing.M) { ctx := context.Background() container, err := wiremocktestcontainersgo.RunContainerAndStopOnCleanup( ctx, - &testing.T{}, // We need a testing.T for the cleanup function, but we'll handle cleanup manually + &testing.T{}, wiremocktestcontainersgo.WithImage("docker.io/wiremock/wiremock:3.9.1"), ) if err != nil { @@ -46,11 +46,10 @@ func TestMain(m *testing.M) { // Run all tests code := m.Run() - // This step is most likely unnecessary // Cleanup - //if WireMockContainer != nil { - // WireMockContainer.Terminate(ctx) - //} + if WireMockContainer != nil { + WireMockContainer.Terminate(ctx) + } // Exit with the same code as the tests os.Exit(code) diff --git a/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go b/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go index 9d693879398..5dbf0c4bcb8 100644 --- a/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go +++ b/seed/go-sdk/imdb/with-wiremock-tests/test/main_test.go @@ -23,7 +23,7 @@ func TestMain(m *testing.M) { ctx := context.Background() container, err := wiremocktestcontainersgo.RunContainerAndStopOnCleanup( ctx, - &testing.T{}, // We need a testing.T for the cleanup function, but we'll handle cleanup manually + &testing.T{}, wiremocktestcontainersgo.WithImage("docker.io/wiremock/wiremock:3.9.1"), ) if err != nil { @@ -46,11 +46,10 @@ func TestMain(m *testing.M) { // Run all tests code := m.Run() - // This step is most likely unnecessary // Cleanup - //if WireMockContainer != nil { - // WireMockContainer.Terminate(ctx) - //} + if WireMockContainer != nil { + WireMockContainer.Terminate(ctx) + } // Exit with the same code as the tests os.Exit(code) From 248b65e81bf4358aff967659181dbbd1676cf027 Mon Sep 17 00:00:00 2001 From: musicpulpite Date: Tue, 16 Sep 2025 15:31:57 -0400 Subject: [PATCH 18/18] ignore new fixture for now --- seed/go-sdk/seed.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/seed/go-sdk/seed.yml b/seed/go-sdk/seed.yml index 3f82bb97774..162c72a6ed8 100644 --- a/seed/go-sdk/seed.yml +++ b/seed/go-sdk/seed.yml @@ -212,3 +212,6 @@ allowedFailures: - examples:exported-client-name - path-parameters:no-custom-config - path-parameters:package-name + + # TODO: Investigate addition of toolchain directive to go.mod + - imdb:with-wiremock-tests