Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions github/repos_releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,73 @@ func (s *RepositoriesService) UploadReleaseAsset(ctx context.Context, owner, rep
}
return asset, resp, nil
}

// UploadReleaseAssetFromRelease uploads an asset using the UploadURL that's embedded
// in a RepositoryRelease object.
//
// This is a convenience wrapper that extracts the release.UploadURL (which is usually
// templated like "https://uploads.github.com/.../assets{?name,label}") and uploads
// the provided data (reader + size) using the existing upload helpers.
Comment on lines +492 to +497
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can add a new example that demonstrates how to use this wrapper?

//
// GitHub API docs: https://docs.github.com/rest/releases/assets#upload-a-release-asset
//
//meta:operation POST /repos/{owner}/{repo}/releases/{release_id}/assets
func (s *RepositoriesService) UploadReleaseAssetFromRelease(
ctx context.Context,
release *RepositoryRelease,
opts *UploadOptions,
reader io.Reader,
size int64,
) (*ReleaseAsset, *Response, error) {
if release == nil || release.UploadURL == nil {
return nil, nil, errors.New("release UploadURL must be provided")
}
if reader == nil {
return nil, nil, errors.New("reader must be provided")
}
if size < 0 {
return nil, nil, errors.New("size must be >= 0")
}

// Strip URI-template portion (e.g. "{?name,label}") if present.
uploadURL := *release.UploadURL
if idx := strings.Index(uploadURL, "{"); idx != -1 {
uploadURL = uploadURL[:idx]
}

// If this is a *relative* URL (no scheme), normalize it by trimming a leading "/"
// so it works with Client.BaseURL path prefixes (e.g. "/api-v3/").
if !strings.HasPrefix(uploadURL, "http://") && !strings.HasPrefix(uploadURL, "https://") {
uploadURL = strings.TrimPrefix(uploadURL, "/")
}

// addOptions will append name/label query params (same behavior as UploadReleaseAsset).
u, err := addOptions(uploadURL, opts)
if err != nil {
return nil, nil, err
}

// determine media type
mediaType := defaultMediaType
if opts != nil && opts.MediaType != "" {
mediaType = opts.MediaType
} else if opts != nil && opts.Name != "" {
if ext := filepath.Ext(opts.Name); ext != "" {
if mt := mime.TypeByExtension(ext); mt != "" {
mediaType = mt
}
}
}
Comment on lines +539 to +547
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's check opts != nil only once.

Suggested change
if opts != nil && opts.MediaType != "" {
mediaType = opts.MediaType
} else if opts != nil && opts.Name != "" {
if ext := filepath.Ext(opts.Name); ext != "" {
if mt := mime.TypeByExtension(ext); mt != "" {
mediaType = mt
}
}
}
if opts != nil {
switch {
case opts.MediaType != "":
mediaType = opts.MediaType
case opts.Name != "":
if ext := filepath.Ext(opts.Name); ext != "" {
if mt := mime.TypeByExtension(ext); mt != "" {
mediaType = mt
}
}
}
}


req, err := s.client.NewUploadRequest(u, reader, size, mediaType)
if err != nil {
return nil, nil, err
}

asset := new(ReleaseAsset)
resp, err := s.client.Do(ctx, req, asset)
if err != nil {
return nil, resp, err
}
return asset, resp, nil
}
215 changes: 209 additions & 6 deletions github/repos_releases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,8 +689,8 @@ func TestRepositoriesService_DeleteReleaseAsset(t *testing.T) {
func TestRepositoriesService_UploadReleaseAsset(t *testing.T) {
t.Parallel()
var (
defaultUploadOptions = &UploadOptions{Name: "n"}
defaultExpectedFormValue = values{"name": "n"}
defaultUploadOptions = &UploadOptions{Name: "n.txt"}
defaultExpectedFormValue = values{"name": "n.txt"}
Comment on lines +692 to +693
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure if we need to modify the test for the UploadReleaseAsset function.

mediaTypeTextPlain = "text/plain; charset=utf-8"
)
uploadTests := []struct {
Expand All @@ -715,23 +715,23 @@ func TestRepositoriesService_UploadReleaseAsset(t *testing.T) {
},
// No file extension and explicit media type.
{
&UploadOptions{Name: "n", MediaType: "image/png"},
&UploadOptions{Name: "n.txt", MediaType: "image/png"},
"upload",
defaultExpectedFormValue,
"image/png",
},
// File extension and explicit media type.
{
&UploadOptions{Name: "n", MediaType: "image/png"},
&UploadOptions{Name: "n.txt", MediaType: "image/png"},
"upload.png",
defaultExpectedFormValue,
"image/png",
},
// Label provided.
{
&UploadOptions{Name: "n", Label: "l"},
&UploadOptions{Name: "n.txt", Label: "l"},
"upload.txt",
values{"name": "n", "label": "l"},
values{"name": "n.txt", "label": "l"},
mediaTypeTextPlain,
},
// No label provided.
Expand Down Expand Up @@ -930,3 +930,206 @@ func TestGenerateNotesOptions_Marshal(t *testing.T) {

testJSONMarshal(t, u, want)
}

func TestRepositoriesService_UploadReleaseAssetFromRelease(t *testing.T) {
t.Parallel()

var (
defaultUploadOptions = &UploadOptions{Name: "n.txt"}
defaultExpectedFormValue = values{"name": "n.txt"}
mediaTypeTextPlain = "text/plain; charset=utf-8"
)

client, mux, _ := setup(t)

// Use the same endpoint path used in other release asset tests.
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testHeader(t, r, "Content-Type", mediaTypeTextPlain)
testHeader(t, r, "Content-Length", "12")
testFormValues(t, r, defaultExpectedFormValue)
testBody(t, r, "Upload me !\n")

fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

// Provide a templated upload URL like GitHub returns.
uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{
UploadURL: &uploadURL,
}

ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, defaultUploadOptions, reader, size)
if err != nil {
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_AbsoluteTemplate(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
// Expect name query param created by addOptions after trimming template.
if got := r.URL.Query().Get("name"); got != "abs.txt" {
t.Errorf("Expected name query param 'abs.txt', got %q", got)
}
fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

// Build an absolute URL using the test client's BaseURL.
absoluteUploadURL := client.BaseURL.String() + "repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &absoluteUploadURL}

opts := &UploadOptions{Name: "abs.txt"}
ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, opts, reader, size)
if err != nil {
t.Fatalf("UploadReleaseAssetFromRelease returned error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NilRelease(t *testing.T) {
t.Parallel()
client, _, _ := setup(t)

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

ctx := t.Context()
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, nil, &UploadOptions{Name: "n.txt"}, reader, size)
if err == nil {
t.Fatal("expected error for nil release, got nil")
}

const methodName = "UploadReleaseAssetFromRelease"
testBadOptions(t, methodName, func() (err error) {
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, nil, &UploadOptions{Name: "n.txt"}, reader, size)
return err
})
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NilReader(t *testing.T) {
t.Parallel()
client, _, _ := setup(t)

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

ctx := t.Context()
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n.txt"}, nil, 12)
if err == nil {
t.Fatal("expected error when reader is nil")
}

const methodName = "UploadReleaseAssetFromRelease"
testBadOptions(t, methodName, func() (err error) {
_, _, err = client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n.txt"}, nil, 12)
return err
})
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NegativeSize(t *testing.T) {
t.Parallel()
client, _, _ := setup(t)

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)

ctx := t.Context()
_, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, &UploadOptions{Name: "n..txt"}, reader, -1)
if err == nil {
t.Fatal("expected error when size is negative")
}
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_NoOpts(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

// No opts: we just assert that the handler is hit and body is as expected.
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testBody(t, r, "Upload me !\n")
fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Upload me !\n")
reader := bytes.NewReader(body)
size := int64(len(body))

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, nil, reader, size)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("Repositories.UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}

const methodName = "UploadReleaseAssetFromRelease"
testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) {
got, resp, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, nil, reader, size)
if got != nil {
t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got)
}
return resp, err
})
}

func TestRepositoriesService_UploadReleaseAssetFromRelease_WithMediaType(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)

// Expect explicit media type to be used.
mux.HandleFunc("/repos/o/r/releases/1/assets", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
testHeader(t, r, "Content-Type", "image/png")
fmt.Fprint(w, `{"id":1}`)
})

body := []byte("Binary!")
reader := bytes.NewReader(body)
size := int64(len(body))

uploadURL := "/repos/o/r/releases/1/assets{?name,label}"
release := &RepositoryRelease{UploadURL: &uploadURL}

opts := &UploadOptions{Name: "n.txt", MediaType: "image/png"}

ctx := t.Context()
asset, _, err := client.Repositories.UploadReleaseAssetFromRelease(ctx, release, opts, reader, size)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := &ReleaseAsset{ID: Ptr(int64(1))}
if !cmp.Equal(asset, want) {
t.Fatalf("UploadReleaseAssetFromRelease returned %+v, want %+v", asset, want)
}
}
Loading