generic: net return full response struct

This is still quite easy to stub, and the extra information is very
useful to the caller.

Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
Yonah 2025-09-19 21:33:16 +09:00
parent 5fe911dd01
commit f48506b1ca
Signed by: yonah
SSH Key Fingerprint: SHA256:vnQvK8+XXH9Tbni2AV1a/8qdVK/zPcXw52GM0ruQvwA
9 changed files with 92 additions and 27 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@
# Content-addressed media files
/data
/data0
# Test binary, built with `go test -c`
*.test

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
)
@ -108,13 +109,16 @@ func (a *Album) Enrich(ctx context.Context, n Net) error {
}
var v AlbumResponse
if body, _, err := n.Get(ctx, APIPrefix+"/album/"+a.CID.String()+"/detail"); err != nil {
u := APIPrefix + "/album/" + a.CID.String() + "/detail"
if resp, err := n.Get(ctx, u); err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
return &ResponseStatusError{u, resp.StatusCode, resp.Body.Close()}
} else {
if err = json.NewDecoder(body).Decode(&v); err != nil {
return errors.Join(body.Close(), err)
if err = json.NewDecoder(resp.Body).Decode(&v); err != nil {
return errors.Join(resp.Body.Close(), err)
}
if err = body.Close(); err != nil {
if err = resp.Body.Close(); err != nil {
return err
}
}

View File

@ -183,6 +183,19 @@ func TestAlbumEnrich(t *testing.T) {
makeAlbumPath(1030, true): []byte{0},
}, Album{}, errors.Join(newSyntaxError("invalid character '\\x00' looking for beginning of value", 1))},
{"status", Album{
CID: 1030,
Name: "Speed of Light",
CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg",
Artists: []string{"塞壬唱片-MSR", "DJ Okawari"},
}, stubNetErrorCloser{
makeAlbumPath(1030, true): []byte{0xde, 0xad, 0xbe, 0xef},
}, Album{}, &ResponseStatusError{
URL: "https://monster-siren.hypergryph.com/api/album/1030/detail",
StatusCode: 0xdeadbeef,
CloseErr: os.ErrInvalid,
}},
{"close", Album{
CID: 1030,
Name: "Speed of Light",

View File

@ -8,6 +8,7 @@ import (
"flag"
"io"
"log"
"net/http"
"os"
"path"
"slices"
@ -97,19 +98,21 @@ func mustFetch(ctx context.Context) {
defer wg.Done()
for u := range uc {
buf := new(bytes.Buffer)
if r, l, err := n.Get(ctx, u); err != nil {
if resp, err := n.Get(ctx, u); err != nil {
log.Fatal(err)
} else if resp.StatusCode != http.StatusOK {
log.Fatal(&monstersirenfetch.ResponseStatusError{URL: u, StatusCode: resp.StatusCode, CloseErr: resp.Body.Close()})
} else {
if v := int(l); v > 0 {
if v := int(resp.ContentLength); v > 0 {
buf.Grow(v)
}
if _, err = io.Copy(buf, r); err != nil {
if closeErr := r.Close(); closeErr != nil {
if _, err = io.Copy(buf, resp.Body); err != nil {
if closeErr := resp.Body.Close(); closeErr != nil {
log.Print(closeErr)
}
log.Fatal(err)
}
if err = r.Close(); err != nil {
if err = resp.Body.Close(); err != nil {
log.Fatal(err)
}
}

View File

@ -2,7 +2,6 @@ package main
import (
"context"
"io"
"net/http"
)
@ -10,12 +9,12 @@ type netDirect struct {
c http.Client
}
func (n *netDirect) Get(ctx context.Context, url string) (io.ReadCloser, int64, error) {
func (n *netDirect) Get(ctx context.Context, url string) (*http.Response, error) {
var resp *http.Response
if req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil); err != nil {
return nil, -2, err
return nil, err
} else if resp, err = n.c.Do(req); err != nil {
return nil, -2, err
return nil, err
}
return resp.Body, resp.ContentLength, nil
return resp, nil
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
@ -30,7 +31,21 @@ func (e *InconsistentEnrichError[T]) Error() string {
type Net interface {
// Get makes a get request to url and returns the response body.
// The caller must close the body after it finishes reading from it.
Get(ctx context.Context, url string) (body io.ReadCloser, contentLength int64, err error)
Get(ctx context.Context, url string) (resp *http.Response, err error)
}
// ResponseStatusError is returned when MSR responds with a StatusCode other than [http.StatusOK].
type ResponseStatusError struct {
// URL is the full uninterpreted url string for this request.
URL string
// StatusCode holds the [http.Response] field of the same name.
StatusCode int
// CloseErr holds the error returned closing the response body.
CloseErr error
}
func (e *ResponseStatusError) Error() string {
return e.URL + " responded with status code " + strconv.Itoa(e.StatusCode)
}
// Metadata represents metadata, often enriched, of the entire website.

View File

@ -6,9 +6,11 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"testing"
"unsafe"
@ -22,6 +24,13 @@ func TestInconsistentEnrichError(t *testing.T) {
}
}
func TestResponseStatusError(t *testing.T) {
const want = "/proc/nonexistent responded with status code 404"
if got := (&ResponseStatusError{URL: "/proc/nonexistent", StatusCode: http.StatusNotFound, CloseErr: os.ErrInvalid}).Error(); got != want {
t.Errorf("Error: %q, want %q", got, want)
}
}
func TestNullableString(t *testing.T) {
checkJSONRoundTripM(t, []jsonRoundTripTestCase[NullableString]{
{"zero", nil, `null`, ""},
@ -116,21 +125,25 @@ func checkJSONRoundTrip[T any](t *testing.T, v T, data []byte) {
type stubNetErrorCloser stubNet
func (n stubNetErrorCloser) Get(ctx context.Context, url string) (io.ReadCloser, int64, error) {
r, l, err := stubNet(n).Get(ctx, url)
if r != nil {
r = errorCloser{r}
func (n stubNetErrorCloser) Get(ctx context.Context, url string) (*http.Response, error) {
resp, err := stubNet(n).Get(ctx, url)
if resp != nil && resp.Body != nil {
resp.Body = errorCloser{resp.Body}
}
return r, l, err
return resp, err
}
type stubNet map[string][]byte
func (n stubNet) Get(_ context.Context, url string) (io.ReadCloser, int64, error) {
func (n stubNet) Get(_ context.Context, url string) (*http.Response, error) {
if b, ok := n[url]; !ok {
return nil, -2, fmt.Errorf("url %s requested, but is not present", url)
return nil, fmt.Errorf("url %s requested, but is not present", url)
} else if string(b) == "\xde\xad\xbe\xef" {
return &http.Response{StatusCode: 0xdeadbeef, ContentLength: 0xdeadbeef,
Body: errorCloser{nopCloser{strings.NewReader("\xde\xad\xbe\xef")}}}, nil
} else {
return nopCloser{bytes.NewReader(b)}, int64(len(b)), nil
return &http.Response{StatusCode: http.StatusOK, ContentLength: int64(len(b)),
Body: nopCloser{bytes.NewReader(b)}}, nil
}
}

12
song.go
View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"slices"
)
@ -79,13 +80,16 @@ func (s *Song) Enrich(ctx context.Context, n Net) error {
}
var v SongResponse
if body, _, err := n.Get(ctx, APIPrefix+"/song/"+s.CID.String()); err != nil {
u := APIPrefix + "/song/" + s.CID.String()
if resp, err := n.Get(ctx, u); err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
return &ResponseStatusError{u, resp.StatusCode, resp.Body.Close()}
} else {
if err = json.NewDecoder(body).Decode(&v); err != nil {
return errors.Join(body.Close(), err)
if err = json.NewDecoder(resp.Body).Decode(&v); err != nil {
return errors.Join(resp.Body.Close(), err)
}
if err = body.Close(); err != nil {
if err = resp.Body.Close(); err != nil {
return err
}
}

View File

@ -113,6 +113,19 @@ func TestSongEnrich(t *testing.T) {
makeSongPath(48794): []byte{0},
}, Song{}, errors.Join(newSyntaxError("invalid character '\\x00' looking for beginning of value", 1))},
{"status", Song{
CID: 48794,
Name: "Warm and Small Light",
AlbumCID: 6660,
Artists: []string{"塞壬唱片-MSR"},
}, stubNet{
makeSongPath(48794): []byte{0xde, 0xad, 0xbe, 0xef},
}, Song{}, &ResponseStatusError{
URL: "https://monster-siren.hypergryph.com/api/song/048794",
StatusCode: 0xdeadbeef,
CloseErr: os.ErrInvalid,
}},
{"close", Song{
CID: 48794,
Name: "Warm and Small Light",