Skip to content

Commit 640b227

Browse files
committed
feat(image): add member endpoints
1 parent 877579d commit 640b227

11 files changed

Lines changed: 499 additions & 8 deletions

File tree

internal/api/image/member.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package image
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"net/http"
7+
8+
"github.com/JSYoo5B/SandStack/internal/api/respond"
9+
appimage "github.com/JSYoo5B/SandStack/internal/app/image"
10+
"github.com/go-chi/chi/v5"
11+
)
12+
13+
func (h Handler) listMembers(w http.ResponseWriter, r *http.Request) {
14+
members, err := h.service.ListMembers(chi.URLParam(r, "image_id"))
15+
if errors.Is(err, appimage.ErrImageNotFound) {
16+
respond.Error(w, http.StatusNotFound, "image not found")
17+
return
18+
}
19+
if err != nil {
20+
respond.Error(w, http.StatusInternalServerError, "image member lookup failed")
21+
return
22+
}
23+
24+
respond.JSON(w, http.StatusOK, memberListResponse{
25+
Members: toMemberDocuments(members),
26+
})
27+
}
28+
29+
func (h Handler) createMember(w http.ResponseWriter, r *http.Request) {
30+
var request createMemberRequest
31+
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
32+
respond.Error(w, http.StatusBadRequest, "invalid JSON request body")
33+
return
34+
}
35+
36+
member, err := h.service.CreateMember(
37+
chi.URLParam(r, "image_id"),
38+
request.Member,
39+
)
40+
if errors.Is(err, appimage.ErrImageNotFound) {
41+
respond.Error(w, http.StatusNotFound, "image not found")
42+
return
43+
}
44+
if err != nil {
45+
respond.Error(w, http.StatusInternalServerError, "image member create failed")
46+
return
47+
}
48+
49+
respond.JSON(w, http.StatusOK, toMemberDocument(member))
50+
}
51+
52+
func (h Handler) getMember(w http.ResponseWriter, r *http.Request) {
53+
member, err := h.service.GetMember(
54+
chi.URLParam(r, "image_id"),
55+
chi.URLParam(r, "member_id"),
56+
)
57+
if errors.Is(err, appimage.ErrImageNotFound) {
58+
respond.Error(w, http.StatusNotFound, "image member not found")
59+
return
60+
}
61+
if err != nil {
62+
respond.Error(w, http.StatusInternalServerError, "image member lookup failed")
63+
return
64+
}
65+
66+
respond.JSON(w, http.StatusOK, toMemberDocument(member))
67+
}
68+
69+
func (h Handler) updateMember(w http.ResponseWriter, r *http.Request) {
70+
var request updateMemberRequest
71+
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
72+
respond.Error(w, http.StatusBadRequest, "invalid JSON request body")
73+
return
74+
}
75+
76+
member, err := h.service.UpdateMember(
77+
chi.URLParam(r, "image_id"),
78+
chi.URLParam(r, "member_id"),
79+
request.Status,
80+
)
81+
if errors.Is(err, appimage.ErrImageNotFound) {
82+
respond.Error(w, http.StatusNotFound, "image member not found")
83+
return
84+
}
85+
if err != nil {
86+
respond.Error(w, http.StatusInternalServerError, "image member update failed")
87+
return
88+
}
89+
90+
respond.JSON(w, http.StatusOK, toMemberDocument(member))
91+
}
92+
93+
func (h Handler) deleteMember(w http.ResponseWriter, r *http.Request) {
94+
err := h.service.DeleteMember(
95+
chi.URLParam(r, "image_id"),
96+
chi.URLParam(r, "member_id"),
97+
)
98+
if errors.Is(err, appimage.ErrImageNotFound) {
99+
respond.Error(w, http.StatusNotFound, "image member not found")
100+
return
101+
}
102+
if err != nil {
103+
respond.Error(w, http.StatusInternalServerError, "image member delete failed")
104+
return
105+
}
106+
107+
w.WriteHeader(http.StatusNoContent)
108+
}

internal/api/image/member_dto.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package image
2+
3+
import appimage "github.com/JSYoo5B/SandStack/internal/app/image"
4+
5+
type createMemberRequest struct {
6+
Member string `json:"member"`
7+
}
8+
9+
type updateMemberRequest struct {
10+
Status string `json:"status"`
11+
}
12+
13+
type memberListResponse struct {
14+
Members []memberDocument `json:"members"`
15+
}
16+
17+
type memberDocument struct {
18+
ImageID string `json:"image_id"`
19+
MemberID string `json:"member_id"`
20+
Status string `json:"status"`
21+
CreatedAt string `json:"created_at"`
22+
UpdatedAt string `json:"updated_at"`
23+
Schema string `json:"schema"`
24+
}
25+
26+
func toMemberDocuments(members []appimage.Member) []memberDocument {
27+
documents := make([]memberDocument, 0, len(members))
28+
for _, member := range members {
29+
documents = append(documents, toMemberDocument(member))
30+
}
31+
32+
return documents
33+
}
34+
35+
func toMemberDocument(member appimage.Member) memberDocument {
36+
return memberDocument{
37+
ImageID: member.ImageID,
38+
MemberID: member.MemberID,
39+
Status: member.Status,
40+
CreatedAt: member.CreatedAt,
41+
UpdatedAt: member.UpdatedAt,
42+
Schema: "/v2/schemas/member",
43+
}
44+
}

internal/api/image/member_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package image_test
2+
3+
import (
4+
"net/http/httptest"
5+
"testing"
6+
7+
"github.com/JSYoo5B/SandStack/internal/api/image"
8+
"github.com/JSYoo5B/SandStack/internal/testhelper"
9+
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
10+
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/members"
11+
"github.com/stretchr/testify/suite"
12+
)
13+
14+
type MemberSuite struct {
15+
suite.Suite
16+
server *httptest.Server
17+
}
18+
19+
func TestMemberSuite(t *testing.T) {
20+
suite.Run(t, new(MemberSuite))
21+
}
22+
23+
func (s *MemberSuite) SetupTest() {
24+
s.server = httptest.NewServer(
25+
image.NewRouter(testhelper.DefaultConfig()),
26+
)
27+
}
28+
29+
func (s *MemberSuite) TearDownTest() {
30+
s.server.Close()
31+
}
32+
33+
func (s *MemberSuite) TestCreateMemberThenListMembers() {
34+
image := s.createImage("ubuntu")
35+
36+
created := s.createMember(image.ID, "project-1")
37+
list := s.listMembers(image.ID)
38+
39+
s.Assert().Equal(image.ID, created.ImageID)
40+
s.Assert().Equal("project-1", created.MemberID)
41+
s.Assert().Equal("pending", created.Status)
42+
s.Require().Len(list, 1)
43+
s.Assert().Equal("project-1", list[0].MemberID)
44+
}
45+
46+
func (s *MemberSuite) TestGetAndUpdateMember() {
47+
image := s.createImage("ubuntu")
48+
created := s.createMember(image.ID, "project-1")
49+
50+
found, err := members.Get(
51+
s.T().Context(),
52+
testhelper.ServiceClient(s.server.URL),
53+
image.ID,
54+
created.MemberID,
55+
).Extract()
56+
s.Require().NoError(err)
57+
s.Require().NotNil(found)
58+
59+
updated, err := members.Update(
60+
s.T().Context(),
61+
testhelper.ServiceClient(s.server.URL),
62+
image.ID,
63+
created.MemberID,
64+
members.UpdateOpts{Status: "accepted"},
65+
).Extract()
66+
s.Require().NoError(err)
67+
s.Require().NotNil(updated)
68+
69+
s.Assert().Equal("project-1", found.MemberID)
70+
s.Assert().Equal("accepted", updated.Status)
71+
}
72+
73+
func (s *MemberSuite) TestDeleteMember() {
74+
image := s.createImage("ubuntu")
75+
created := s.createMember(image.ID, "project-1")
76+
77+
err := members.Delete(
78+
s.T().Context(),
79+
testhelper.ServiceClient(s.server.URL),
80+
image.ID,
81+
created.MemberID,
82+
).ExtractErr()
83+
s.Require().NoError(err)
84+
85+
list := s.listMembers(image.ID)
86+
87+
s.Assert().Empty(list)
88+
}
89+
90+
func (s *MemberSuite) listMembers(imageID string) []members.Member {
91+
pages, err := members.List(
92+
testhelper.ServiceClient(s.server.URL),
93+
imageID,
94+
).AllPages(s.T().Context())
95+
s.Require().NoError(err)
96+
97+
list, err := members.ExtractMembers(pages)
98+
s.Require().NoError(err)
99+
100+
return list
101+
}
102+
103+
func (s *MemberSuite) createMember(
104+
imageID string,
105+
memberID string,
106+
) *members.Member {
107+
created, err := members.Create(
108+
s.T().Context(),
109+
testhelper.ServiceClient(s.server.URL),
110+
imageID,
111+
memberID,
112+
).Extract()
113+
s.Require().NoError(err)
114+
s.Require().NotNil(created)
115+
116+
return created
117+
}
118+
119+
func (s *MemberSuite) createImage(name string) *images.Image {
120+
created, err := images.Create(
121+
s.T().Context(),
122+
testhelper.ServiceClient(s.server.URL),
123+
images.CreateOpts{
124+
Name: name,
125+
ContainerFormat: "bare",
126+
DiskFormat: "qcow2",
127+
},
128+
).Extract()
129+
s.Require().NoError(err)
130+
s.Require().NotNil(created)
131+
132+
return created
133+
}

internal/api/image/router.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func NewHandler(cfg config.Config) Handler {
3333
appimage.NewServiceWithRuntime(
3434
storeimage.NewMemoryRepository(),
3535
storeimage.NewMemoryDataRepository(),
36+
storeimage.NewMemoryMemberRepository(),
3637
clock.Wall(),
3738
idgen.Random(),
3839
),
@@ -59,6 +60,11 @@ func (h Handler) Router() http.Handler {
5960
router.Delete("/images/{image_id}", h.deleteImage)
6061
router.Put("/images/{image_id}/file", h.uploadImageData)
6162
router.Get("/images/{image_id}/file", h.downloadImageData)
63+
router.Get("/images/{image_id}/members", h.listMembers)
64+
router.Post("/images/{image_id}/members", h.createMember)
65+
router.Get("/images/{image_id}/members/{member_id}", h.getMember)
66+
router.Put("/images/{image_id}/members/{member_id}", h.updateMember)
67+
router.Delete("/images/{image_id}/members/{member_id}", h.deleteMember)
6268

6369
return router
6470
}

internal/api/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ func NewRouter(cfg config.Config) http.Handler {
5555
imageService := appimage.NewServiceWithRuntime(
5656
storeimage.NewMemoryRepository(),
5757
storeimage.NewMemoryDataRepository(),
58+
storeimage.NewMemoryMemberRepository(),
5859
clock.Wall(),
5960
idgen.Random(),
6061
)

internal/app/image/member.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package image
2+
3+
import "time"
4+
5+
func (s *Service) CreateMember(imageID string, memberID string) (Member, error) {
6+
if _, err := s.repository.Get(imageID); err != nil {
7+
return Member{}, err
8+
}
9+
10+
now := s.clock.Now().UTC().Format(time.RFC3339)
11+
member := Member{
12+
ImageID: imageID,
13+
MemberID: memberID,
14+
Status: "pending",
15+
CreatedAt: now,
16+
UpdatedAt: now,
17+
}
18+
19+
return s.memberRepository.Create(member), nil
20+
}
21+
22+
func (s *Service) ListMembers(imageID string) ([]Member, error) {
23+
if _, err := s.repository.Get(imageID); err != nil {
24+
return nil, err
25+
}
26+
27+
return s.memberRepository.List(imageID), nil
28+
}
29+
30+
func (s *Service) GetMember(imageID string, memberID string) (Member, error) {
31+
if _, err := s.repository.Get(imageID); err != nil {
32+
return Member{}, err
33+
}
34+
35+
return s.memberRepository.Get(imageID, memberID)
36+
}
37+
38+
func (s *Service) UpdateMember(
39+
imageID string,
40+
memberID string,
41+
status string,
42+
) (Member, error) {
43+
member, err := s.GetMember(imageID, memberID)
44+
if err != nil {
45+
return Member{}, err
46+
}
47+
48+
member.Status = status
49+
member.UpdatedAt = s.clock.Now().UTC().Format(time.RFC3339)
50+
51+
return s.memberRepository.Update(member)
52+
}
53+
54+
func (s *Service) DeleteMember(imageID string, memberID string) error {
55+
if _, err := s.repository.Get(imageID); err != nil {
56+
return err
57+
}
58+
59+
return s.memberRepository.Delete(imageID, memberID)
60+
}

internal/app/image/repository.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ type DataRepository interface {
1515
Delete(id string)
1616
Reset()
1717
}
18+
19+
type MemberRepository interface {
20+
Create(member Member) Member
21+
List(imageID string) []Member
22+
Get(imageID string, memberID string) (Member, error)
23+
Update(member Member) (Member, error)
24+
Delete(imageID string, memberID string) error
25+
Reset()
26+
}

0 commit comments

Comments
 (0)