Skip to content

[go] support io.Reader and []byte response types in client decode#23789

Merged
wing328 merged 1 commit into
OpenAPITools:masterfrom
dschmidt:fix/go-decode-io-reader
May 14, 2026
Merged

[go] support io.Reader and []byte response types in client decode#23789
wing328 merged 1 commit into
OpenAPITools:masterfrom
dschmidt:fix/go-decode-io-reader

Conversation

@dschmidt
Copy link
Copy Markdown
Contributor

@dschmidt dschmidt commented May 14, 2026

The Go generator maps type: string + format: binary to *os.File by default. Consumers can override that with --type-mappings to get io.Reader or []byte.

setBody already has branches for both, so request bodies work. The shared decode helper does not: it only asserts *string, *os.File, and **os.File. When Execute declares var localVarReturnValue io.Reader or []byte and calls decode(&localVarReturnValue, ...), no branch matches and the call returns "undefined response type".

This PR adds two decode branches that close the symmetry with setBody:

if r, ok := v.(*io.Reader); ok {
    *r = bytes.NewReader(b)
    return nil
}
// Must stay before the JSON branch: json.Unmarshal would base64-decode into *[]byte.
if p, ok := v.(*[]byte); ok {
    *p = b
    return nil
}

bytes is already imported in every generated client.go. The same additive patch is applied to the eleven Go client samples.

Notes on choice of type

Both mappings are useful but for different scenarios:

  • []byte is honest: the payload is fully buffered in memory anyway (the caller pre-slurps the response body for shared error/success handling). Callers get the bytes directly with no wrapper.
  • io.Reader is forward-compatible: if the response path is ever refactored to skip the eager buffering and stream the body, the same return type stays valid for consumers without changing call sites.

Neither offers true streaming today; that would require changes to api.mustache and is out of scope here.

Verification

Generated the libre-graph-api spec with the type mappings against this branch and ran httptest-backed integration tests covering both shapes:

  • io.Reader upload (plain io.Reader, *os.File for backward compat, 1 MiB bytes.Reader), io.Reader download, empty-body download.
  • []byte upload, []byte download, empty-body download.

All pass.

PR checklist

  • Read the contribution guidelines.
  • PR title clearly describes the work.
  • Samples updated. ./mvnw clean package was not run locally (no JDK), but the change is purely additive and isolated to one block in decode. No other template, generator, or model output is affected.
  • Filed against master.

cc @antihax @grokify @kemokemo @jirikuncar @ph4r5h4d @lwj5

@dschmidt dschmidt force-pushed the fix/go-decode-io-reader branch from e58bee9 to 746935a Compare May 14, 2026 13:49
@dschmidt dschmidt changed the title [go] support io.Reader response types in client decode [go] support io.Reader and []byte response types in client decode May 14, 2026
@dschmidt dschmidt force-pushed the fix/go-decode-io-reader branch from 746935a to a8c1c98 Compare May 14, 2026 14:04
The Go generator maps binary types to *os.File by default. Callers can
override that with --type-mappings to get io.Reader or []byte, and
setBody already handles both for request bodies. The shared decode
helper, however, only asserts *string, *os.File, and **os.File. When
Execute declares localVarReturnValue io.Reader or []byte and calls
decode(&localVarReturnValue, ...), no branch matches and the call
returns 'undefined response type'.

Add two branches:
- *io.Reader wraps the already-buffered bytes in a bytes.Reader.
- *[]byte assigns the bytes directly. This branch must stay before the
  JSON branch, since json.Unmarshal accepts *[]byte and base64-decodes
  into it, which is not what we want for raw binary responses.

Both shapes are useful for different scenarios: []byte is more honest
(the response is fully buffered in memory anyway), while io.Reader is
forward-compatible (the return type stays valid if the response path is
ever refactored to skip the eager buffering and stream the body).

bytes is already imported in every generated client.go. The same
additive patch is applied to the eleven Go client samples.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 12 files

@wing328
Copy link
Copy Markdown
Member

wing328 commented May 14, 2026

@dschmidt thanks for the PR

is it correct to say you've been using this fix in your production environment for a while to confirm it works for your use cases?

@dschmidt
Copy link
Copy Markdown
Contributor Author

Honest answer: not in production yet. We hit the gap while preparing an opencloud-eu/libre-graph-api change that flips the generator's binary types to io.Reader, and held that PR back until this one lands.

We did verify both shapes end-to-end against a regenerated client with httptest-backed integration tests. The full suite is below for transparency.

io.Reader integration tests (5 cases, all pass)

Regenerated with --type-mappings=binary=io.Reader,file=io.Reader,File=io.Reader -t /path/to/patched/go/templates.

package phototest

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	libregraph "github.com/GIT_USER_ID/GIT_REPO_ID"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

var fakeJPEG = []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10}

func newClient(t *testing.T, h http.Handler) *libregraph.APIClient {
	t.Helper()
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	cfg := libregraph.NewConfiguration()
	cfg.Servers = libregraph.ServerConfigurations{{URL: srv.URL}}
	return libregraph.NewAPIClient(cfg)
}

func makeTempFile(t *testing.T, content []byte) (*os.File, error) {
	t.Helper()
	f, err := os.CreateTemp(t.TempDir(), "photo-*.jpg")
	if err != nil {
		return nil, err
	}
	if _, err := f.Write(content); err != nil {
		return nil, err
	}
	if _, err := f.Seek(0, 0); err != nil {
		return nil, err
	}
	return f, nil
}

func TestUpdateOwnUserPhotoPut_AcceptsIOReader(t *testing.T) {
	var (
		gotMethod      string
		gotContentType string
		gotBody        []byte
	)
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		gotContentType = r.Header.Get("Content-Type")
		b, err := io.ReadAll(r.Body)
		require.NoError(t, err)
		gotBody = b
		w.WriteHeader(http.StatusNoContent)
	}))

	var body io.Reader = strings.NewReader(string(fakeJPEG))

	resp, err := client.MePhotoApi.
		UpdateOwnUserPhotoPut(context.Background()).
		Body(body).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusNoContent, resp.StatusCode)
	assert.Equal(t, http.MethodPut, gotMethod)
	assert.Equal(t, "image/jpeg", gotContentType)
	assert.Equal(t, fakeJPEG, gotBody)
}

func TestUpdateOwnUserPhotoPut_AcceptsOSFile(t *testing.T) {
	// *os.File still satisfies io.Reader: backward-compat check.
	var gotBody []byte
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		gotBody = b
		w.WriteHeader(http.StatusNoContent)
	}))

	tmp, err := makeTempFile(t, fakeJPEG)
	require.NoError(t, err)

	resp, err := client.MePhotoApi.
		UpdateOwnUserPhotoPut(context.Background()).
		Body(tmp).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusNoContent, resp.StatusCode)
	assert.Equal(t, fakeJPEG, gotBody)
}

func TestGetOwnUserPhoto_ReturnsIOReader(t *testing.T) {
	// This is the case that previously failed with "undefined response type".
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		assert.Equal(t, http.MethodGet, r.Method)
		w.Header().Set("Content-Type", "image/jpeg")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write(fakeJPEG)
	}))

	reader, resp, err := client.MePhotoApi.
		GetOwnUserPhoto(context.Background()).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusOK, resp.StatusCode)
	require.NotNil(t, reader)

	got, err := io.ReadAll(reader)
	require.NoError(t, err)
	assert.Equal(t, fakeJPEG, got)
}

func TestGetOwnUserPhoto_EmptyBody(t *testing.T) {
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "image/jpeg")
		w.WriteHeader(http.StatusOK)
	}))

	reader, resp, err := client.MePhotoApi.
		GetOwnUserPhoto(context.Background()).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusOK, resp.StatusCode)
	// decode short-circuits on len(b) == 0, so reader stays nil.
	if reader != nil {
		got, err := io.ReadAll(reader)
		require.NoError(t, err)
		assert.Empty(t, got)
	}
}

func TestUpdateOwnUserPhotoPut_LargeBody(t *testing.T) {
	// 1 MiB payload via bytes.Reader.
	payload := bytes.Repeat([]byte{0xab}, 1<<20)

	var gotLen int
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		gotLen = len(b)
		w.WriteHeader(http.StatusNoContent)
	}))

	resp, err := client.MePhotoApi.
		UpdateOwnUserPhotoPut(context.Background()).
		Body(bytes.NewReader(payload)).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusNoContent, resp.StatusCode)
	assert.Equal(t, len(payload), gotLen)
}
[]byte integration tests (3 cases, all pass)

Regenerated with --type-mappings=binary=[]byte,file=[]byte,File=[]byte.

package phototest

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	libregraph "github.com/GIT_USER_ID/GIT_REPO_ID"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

var fakeJPEG = []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10}

func newClient(t *testing.T, h http.Handler) *libregraph.APIClient {
	t.Helper()
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	cfg := libregraph.NewConfiguration()
	cfg.Servers = libregraph.ServerConfigurations{{URL: srv.URL}}
	return libregraph.NewAPIClient(cfg)
}

func TestUpdateOwnUserPhotoPut_BytesUpload(t *testing.T) {
	var gotMethod, gotContentType string
	var gotBody []byte
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		gotContentType = r.Header.Get("Content-Type")
		gotBody, _ = io.ReadAll(r.Body)
		w.WriteHeader(http.StatusNoContent)
	}))

	resp, err := client.MePhotoApi.
		UpdateOwnUserPhotoPut(context.Background()).
		Body(fakeJPEG).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusNoContent, resp.StatusCode)
	assert.Equal(t, http.MethodPut, gotMethod)
	assert.Equal(t, "image/jpeg", gotContentType)
	assert.Equal(t, fakeJPEG, gotBody)
}

func TestGetOwnUserPhoto_BytesDownload(t *testing.T) {
	// Without the *[]byte branch this hits the JSON path and fails on image/jpeg.
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "image/jpeg")
		_, _ = w.Write(fakeJPEG)
	}))

	got, resp, err := client.MePhotoApi.
		GetOwnUserPhoto(context.Background()).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Equal(t, fakeJPEG, got)
}

func TestGetOwnUserPhoto_BytesDownload_EmptyBody(t *testing.T) {
	client := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "image/jpeg")
		w.WriteHeader(http.StatusOK)
	}))

	got, resp, err := client.MePhotoApi.
		GetOwnUserPhoto(context.Background()).
		Execute()
	require.NoError(t, err)
	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Empty(t, got)
}

Both suites green against the patched template. Without the patch, the download tests fail with "undefined response type".

@wing328
Copy link
Copy Markdown
Member

wing328 commented May 14, 2026

thanks for sharing more.

let's give it a try

thank you for the contribution.

@wing328 wing328 merged commit e7466e5 into OpenAPITools:master May 14, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants