Add GET /api/v1/character/details with each image's change time
CI / integration (push) Successful in 21s
CI / test (push) Successful in 23s
CI / publish (push) Skipped
CI / docker (push) Successful in 48s

Returns every character image with when it was last changed, in Unix seconds,
so clients can cache the images and download only the ones that changed.
This commit is contained in:
Sansan committed 2026-10-07 21:57:26 +02:00
1 parent 76a1b946eb
commit c62b65d7cd
5 files changed
+72

No files matched your search

+27
View File
@@ -37,6 +37,33 @@ func (c *CharacterService) List() ([]string, error) {
return characters, nil
}
// CharacterDetails is a character image with the time its file was last changed, so a
// client that caches images knows when to download one again.
type CharacterDetails struct {
Name string `json:"name" doc:"File name of the character image"`
Modified int64 `json:"modified" doc:"When the image was last changed, in Unix seconds"`
}
// ListDetails returns all character images with the time each was last changed.
func (c *CharacterService) ListDetails() ([]CharacterDetails, error) {
entries, err := fs.ReadDir(c.root.FS(), ".")
if err != nil {
return nil, err
}
characters := []CharacterDetails{}
for _, entry := range entries {
if entry.IsDir() || !isImage(entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
characters = append(characters, CharacterDetails{Name: entry.Name(), Modified: info.ModTime().Unix()})
}
return characters, nil
}
// Open opens the character image with the given file name.
func (c *CharacterService) Open(name string) (*os.File, error) {
if name == "" || name != filepath.Base(name) || !isImage(name) {
+27
View File
@@ -4,6 +4,7 @@ import (
"io"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -31,6 +32,32 @@ func TestCharacterList(t *testing.T) {
assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters)
}
func TestCharacterListDetails(t *testing.T) {
c := newCharacterService(t)
changed := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
require.NoError(t, c.root.Chtimes("Adol.png", changed, changed))
characters, err := c.ListDetails()
require.NoError(t, err)
require.Len(t, characters, 2)
assert.Equal(t, CharacterDetails{Name: "Adol.png", Modified: changed.Unix()}, characters[0])
assert.Equal(t, "Aloy.JPG", characters[1].Name)
}
func TestCharacterListDetailsSeesChangedImage(t *testing.T) {
c := newCharacterService(t)
before, err := c.ListDetails()
require.NoError(t, err)
later := time.Unix(before[0].Modified, 0).Add(time.Hour)
require.NoError(t, c.root.Chtimes("Adol.png", later, later))
after, err := c.ListDetails()
require.NoError(t, err)
assert.Greater(t, after[0].Modified, before[0].Modified)
assert.Equal(t, before[1], after[1], "unchanged images keep their time")
}
func TestCharacterOpen(t *testing.T) {
c := newCharacterService(t)
+10
View File
@@ -167,6 +167,16 @@ func TestCharacterEndpoints(t *testing.T) {
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
var details []struct {
Name string `json:"name"`
Modified int64 `json:"modified"`
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details))
require.Len(t, details, 2)
assert.Equal(t, "Adol.png", details[0].Name)
assert.Equal(t, "Aloy.jpg", details[1].Name)
assert.Positive(t, details[0].Modified)
rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
+6
View File
@@ -57,6 +57,12 @@ func (s *Server) registerMisc(a huma.API) {
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(s.characters.List())
})
register(s, a, route{op: get("list-character-details", "/character/details", "characters",
"List characters with change times",
"Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.")},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.CharacterDetails], error) {
return ok(s.characters.ListDetails())
})
image := get("get-character", "/character", "characters", "Character image",
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
image.Responses = fileResponses("The image", "image/png", "image/jpeg")
+2
View File
@@ -76,6 +76,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
assert.Equal(t, []string{
"DELETE /api/v1/token",
"GET /api/v1/character",
"GET /api/v1/character/details",
"GET /api/v1/character/list",
"GET /api/v1/download",
"GET /api/v1/download/linux",
@@ -207,6 +208,7 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
// Characters, downloads, server info.
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character/details", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest)
v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)