From 39b86d4c3a8e5c2c9a7feae8c185eded986aeaca Mon Sep 17 00:00:00 2001 From: parath Date: Thu, 11 Sep 2025 17:34:04 +0300 Subject: [PATCH 1/9] favourites REST API server and tests --- Dockerfile | 21 ++++++ README.md | 99 +++++++++++++++++++++++----- go.mod | 5 ++ go.sum | 2 + main.go | 163 ++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 18 deletions(-) create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e5f47abb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download +COPY . . + +RUN go build -o favourites main.go + +# Runtime stage +FROM alpine:3.22 +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ +COPY --from=builder /app/favourites . + +EXPOSE 8080 +CMD ["./favourites"] diff --git a/README.md b/README.md index 3e004639..08dda9b6 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,94 @@ -# GlobalWebIndex Engineering Challenge +# User Favourites Service -## Introduction +## Scope +A web server that lets users manage their favourite assets including charts, insights and audiences. +Supports listing, adding, removing and editing favourites. -This challenge is designed to give you the opportunity to demonstrate your abilities as a software engineer and specifically your knowledge of the Go language. +## How to Run -On the surface the challenge is trivial to solve, however you should choose to add features or capabilities which you feel demonstrate your skills and knowledge the best. For example, you could choose to optimise for performance and concurrency, you could choose to add a robust security layer or ensure your application is highly available. Or all of these. +### Run locally +To build and run the program: +```bash +go run main.go +``` +Or, if you want to build first: +```bash +go build -o favourites +./favourites +``` -Of course, usually we would choose to solve any given requirement with the simplest possible solution, however that is not the spirit of this challenge. +### Run with Docker +```bash +docker build -t favourites . +docker run --rm -p 8080:8080 favourites +``` -## Challenge +## How to Test +Tests cover basic functionality and a few error scenarios. -Let's say that in GWI platform all of our users have access to a huge list of assets. We want our users to have a peronal list of favourites, meaning assets that favourite or “star” so that they have them in their frontpage dashboard for quick access. An asset can be one the following -* Chart (that has a small title, axes titles and data) -* Insight (a small piece of text that provides some insight into a topic, e.g. "40% of millenials spend more than 3hours on social media daily") -* Audience (which is a series of characteristics, for that exercise lets focus on gender (Male, Female), birth country, age groups, hours spent daily on social media, number of purchases last month) -e.g. Males from 24-35 that spent more than 3 hours on social media daily. +Run them with: +```bash +go test ./... +``` -Build a web server which has some endpoint to receive a user id and return a list of all the user’s favourites. Also we want endpoints that would add an asset to favourites, remove it, or edit its description. Assets obviously can share some common attributes (like their description) but they also have completely different structure and data. It’s up to you to decide the structure and we are not looking for something overly complex here (especially for the cases of audiences). There is no need to have/deploy/create an actual database although we would like to discuss about storage options and data representations. +## Usage ecamples +Add a favourite +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"assetId":"chart-42", "assetType":"chart", "description":"Top sales", "metadata":{"title":"Sales Q4","axes":["month","revenue"]}}' \ + http://localhost:8080/favourites/user123 +``` +`Response 201, with created entry including generated id` -Note that users have no limit on how many assets they want on their favourites so your service will need to provide a reasonable response time. +``` +{ + "id": "fav-1", + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "metadata": { "title": "Sales Q4", "axes": ["month","revenue"] }, + "createdAt": "2025-09-11T17:18:53.9766385+03:00" +} +``` -A working server application with functional API is required, along with a clear readme.md. Useful and passing tests would be also be viewed favourably +List of favourites per user +```bash +curl -s http://localhost:8080/favourites/user123 | jq +``` +`Response 200` -It is appreciated, though not required, if a Dockerfile is included. + ``` + [ + { + "id": "fav-1", + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "metadata": { "title": "Sales Q4", "axes": ["month","revenue"] }, + "createdAt": "2025-09-11T17:18:53.9766385+03:00" + } + ] + ``` -## Submission +Update an asset in favourites +```bash +curl -X PUT -H "Content-Type: application/json" \ + -d '{"assetId":"chart-21", "assetType":"chart", "description":"Sales frequency", "metadata":{"title":"Annual retention","axes":["month","invoice_count"]}}' \ + http://localhost:8080/favourites/user123/fav-1 +``` -Just create a fork from the current repo and send it to us! +Delete a favourite +```bash +curl -X DELETE http://localhost:8080/favourites/USER123/fav-1 +``` -Good luck, potential colleague! +## Assumptions +- REST API endpoints to fetch, add, remove and update assets in favourites list +- JSON request/response +- In-memory store for the challenge purposes +- Unit tests for GET, POST, PUT, PATCH and DELETE verbs +- assetId is handled by other platform services. Favourites service does not validate asset existence. + +## Next steps +- Ephemeral storage, currently in memory, to chaνge into platform-wide storage service(s) +- Scalability issues: pagination for large lists, persistent storage (Redis/Postgres JSONB), caching diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..5598cb4c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/parath/platform-go-challenge + +go 1.25 + +require github.com/gorilla/mux v1.8.1 diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..71283374 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/main.go b/main.go new file mode 100644 index 00000000..f8262406 --- /dev/null +++ b/main.go @@ -0,0 +1,163 @@ +/* +Favourites is a simple web server that manages a per-user list of favourite assets. +*/ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/mux" +) + +// Favourite represents a user's favourite asset. +// Common attributes (ID, UserID, AssetID, AssetType, Description, CreatedAt) +// are top-level fields. Asset-specific data (like chart axes or audience criteria) +// is stored as free-form JSON in Metadata for flexibility. +type Favourite struct { + ID string + UserID string + AssetID string + AssetType string + Description string + Metadata map[string]interface{} + CreatedAt time.Time +} + +// InMemoryStore stores favourites in memory per user +type InMemoryStore struct { + mu sync.RWMutex + store map[string][]Favourite + nextID int +} + +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{store: make(map[string][]Favourite)} +} + +func (s *InMemoryStore) AddFavourite(f Favourite) { + s.mu.Lock() + defer s.mu.Unlock() + s.store[f.UserID] = append(s.store[f.UserID], f) +} + +func (s *InMemoryStore) GetFavourites(userID string) []Favourite { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]Favourite(nil), s.store[userID]...) +} + +func (s *InMemoryStore) UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, bool) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.store[userID] + for i := range list { + if list[i].ID == favouriteID { + // Update only mutable fields on the existing entry; keep immutable ones + existing := list[i] + existing.AssetID = update.AssetID + existing.AssetType = update.AssetType + existing.Description = update.Description + existing.Metadata = update.Metadata + list[i] = existing + s.store[userID] = list + return existing, true + } + } + return Favourite{}, false +} + +func (s *InMemoryStore) DeleteFavourite(userID, favouriteID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + list := s.store[userID] + for i := range list { + if list[i].ID == favouriteID { + list[i] = list[len(list)-1] + list = list[:len(list)-1] + s.store[userID] = list + return true + } + } + return false +} + +func (s *InMemoryStore) NextFavouriteID() string { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + return fmt.Sprintf("fav-%d", s.nextID) +} + +var store = NewInMemoryStore() + +// Handlers +func getFavouritesHandler(w http.ResponseWriter, r *http.Request) { + userID := mux.Vars(r)["userId"] + favs := store.GetFavourites(userID) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(favs) +} + +func addFavouriteHandler(w http.ResponseWriter, r *http.Request) { + userID := mux.Vars(r)["userId"] + var f Favourite + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + f.UserID = userID + f.ID = store.NextFavouriteID() + f.CreatedAt = time.Now() + store.AddFavourite(f) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(f) +} + +func updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + favID := vars["id"] + var upd Favourite + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + updated, ok := store.UpdateFavourite(userID, favID, upd) + if !ok { + http.Error(w, "favourite not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(updated) +} + +func deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + favID := vars["id"] + if ok := store.DeleteFavourite(userID, favID); !ok { + http.Error(w, "favourite not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func main() { + r := mux.NewRouter() + r.HandleFunc("/favourites/{userId}", getFavouritesHandler).Methods("GET") + r.HandleFunc("/favourites/{userId}", addFavouriteHandler).Methods("POST") + r.HandleFunc("/favourites/{userId}/{id}", updateFavouriteHandler).Methods("PUT", "PATCH") + r.HandleFunc("/favourites/{userId}/{id}", deleteFavouriteHandler).Methods("DELETE") + + log.Println("Server listening on :8080") + if err := http.ListenAndServe(":8080", r); err != nil { + log.Fatal(err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..f3b7f9a4 --- /dev/null +++ b/main_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +// newTestServer creates a router with handlers and resets the global store for isolation +func newTestServer() *mux.Router { + store = NewInMemoryStore() + r := mux.NewRouter() + r.HandleFunc("/favourites/{userId}", getFavouritesHandler).Methods("GET") + r.HandleFunc("/favourites/{userId}", addFavouriteHandler).Methods("POST") + r.HandleFunc("/favourites/{userId}/{id}", updateFavouriteHandler).Methods("PUT", "PATCH") + r.HandleFunc("/favourites/{userId}/{id}", deleteFavouriteHandler).Methods("DELETE") + return r +} + +func doRequest(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != nil { + switch b := body.(type) { + case []byte: + reader = bytes.NewReader(b) + case string: + reader = bytes.NewBufferString(b) + default: + data, err := json.Marshal(b) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + reader = bytes.NewReader(data) + } + } + req := httptest.NewRequest(method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + return rr +} + +func TestGetFavourites_Empty(t *testing.T) { + r := newTestServer() + rr := doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var favs []Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &favs); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(favs) != 0 { + t.Fatalf("expected empty list, got %d", len(favs)) + } +} + +func TestPostFavourite_CreateAndList(t *testing.T) { + r := newTestServer() + // Create + payload := map[string]any{ + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "metadata": map[string]any{"title": "Sales Q4"}, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("invalid json: %v", err) + } + if created.ID == "" || created.UserID != "user-1" || created.AssetID != "chart-42" { + t.Fatalf("unexpected created favourite: %+v", created) + } + // List + rr = doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var favs []Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &favs); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(favs) != 1 || favs[0].ID != created.ID { + t.Fatalf("expected one favourite matching created, got: %+v", favs) + } +} + +func TestPutFavourite_UpdatePreservesImmutable(t *testing.T) { + r := newTestServer() + // Create first + created := createFavourite(t, r, "user-1", map[string]any{ + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "metadata": map[string]any{"title": "Sales Q4"}, + }) + + // Attempt update changing id/user/createdAt (should be ignored/preserved) + upd := Favourite{ + ID: "should-not-change", + UserID: "other-user", + AssetID: "chart-99", + AssetType: "chart", + Description: "Updated", + Metadata: map[string]any{"title": "New"}, + CreatedAt: created.CreatedAt.Add(24 * 60 * 60 * 1e9), + } + path := "/favourites/" + created.UserID + "/" + created.ID + rr := doRequest(t, r, http.MethodPut, path, upd) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var got Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("invalid json: %v", err) + } + if got.ID != created.ID || got.UserID != created.UserID || !got.CreatedAt.Equal(created.CreatedAt) { + t.Fatalf("immutable fields changed: before=%+v after=%+v", created, got) + } + if got.AssetID != "chart-99" || got.Description != "Updated" { + t.Fatalf("mutable fields not updated: %+v", got) + } +} + +func TestPutFavourite_NotFound(t *testing.T) { + r := newTestServer() + upd := map[string]any{"assetId": "chart-1"} + rr := doRequest(t, r, http.MethodPut, "/favourites/user-1/fav-999", upd) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestDeleteFavourite(t *testing.T) { + r := newTestServer() + created := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1"}) + // Delete existing + rr := doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rr.Code) + } + // Delete again -> 404 + rr = doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestPostFavourite_InvalidBody(t *testing.T) { + r := newTestServer() + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", "not-json") + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +// helper to create a favourite and parse response +func createFavourite(t *testing.T, r http.Handler, userID string, body map[string]any) Favourite { + t.Helper() + rr := doRequest(t, r, http.MethodPost, "/favourites/"+userID, body) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("invalid json: %v", err) + } + return created +} From 32038ec85943910bc4674cef14cbb079150c31c2 Mon Sep 17 00:00:00 2001 From: parath Date: Fri, 12 Sep 2025 15:10:40 +0300 Subject: [PATCH 2/9] removed globals and created structure --- README.md | 10 +- internal/httpapi/server.go | 108 +++++++++++++++++ internal/httpapi/server_test.go | 199 ++++++++++++++++++++++++++++++++ main.go | 153 +----------------------- main_test.go | 175 ++-------------------------- 5 files changed, 326 insertions(+), 319 deletions(-) create mode 100644 internal/httpapi/server.go create mode 100644 internal/httpapi/server_test.go diff --git a/README.md b/README.md index 08dda9b6..ef3353a7 100644 --- a/README.md +++ b/README.md @@ -79,16 +79,16 @@ curl -X PUT -H "Content-Type: application/json" \ Delete a favourite ```bash -curl -X DELETE http://localhost:8080/favourites/USER123/fav-1 +curl -X DELETE http://localhost:8080/favourites/user123/fav-1 ``` ## Assumptions - REST API endpoints to fetch, add, remove and update assets in favourites list - JSON request/response - In-memory store for the challenge purposes -- Unit tests for GET, POST, PUT, PATCH and DELETE verbs -- assetId is handled by other platform services. Favourites service does not validate asset existence. +- Tests for GET, POST, PUT, PATCH and DELETE verbs and store functions ## Next steps -- Ephemeral storage, currently in memory, to chaνge into platform-wide storage service(s) -- Scalability issues: pagination for large lists, persistent storage (Redis/Postgres JSONB), caching +- Solution is base on in-memory store which makes storage ephemeral and works for single instace only. Persistent storage should be used, for instance Postgres JSONB, or adopt with platform-wide storage solution. +- Performance: caching per user with Redis since it is shown in the frontpage +- Performance: pagination for very large lists diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 00000000..806a38f3 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,108 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/parath/platform-go-challenge/internal/favourites" +) + +type Server struct { + store favourites.Store +} + +func NewServer(store favourites.Store) *mux.Router { + s := &Server{store: store} + r := mux.NewRouter() + r.HandleFunc("/favourites/{userId}", s.getFavouritesHandler).Methods("GET") + r.HandleFunc("/favourites/{userId}", s.addFavouriteHandler).Methods("POST") + r.HandleFunc("/favourites/{userId}/{id}", s.updateFavouriteHandler).Methods("PUT", "PATCH") + r.HandleFunc("/favourites/{userId}/{id}", s.deleteFavouriteHandler).Methods("DELETE") + return r +} + +// Handlers +func (s *Server) getFavouritesHandler(w http.ResponseWriter, r *http.Request) { + userID := mux.Vars(r)["userId"] + favs, err := s.store.GetFavourites(userID) + if err != nil { + http.Error(w, "failed to get favourites", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(favs); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } +} + +func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { + userID := mux.Vars(r)["userId"] + var f favourites.Favourite + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + f.UserID = userID + f.ID = s.store.NextFavouriteID() + f.CreatedAt = time.Now() + err := s.store.AddFavourite(f) + if err != nil { + http.Error(w, "failed to add favourite", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(f); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } +} + +func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + favID := vars["id"] + var upd favourites.Favourite + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + updated, err := s.store.UpdateFavourite(userID, favID, upd) + if err != nil { + if errors.Is(err, favourites.ErrNotFound) { + http.Error(w, err.Error(), http.StatusNotFound) + } else { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(updated); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } +} + +func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userId"] + favID := vars["id"] + err := s.store.DeleteFavourite(userID, favID) + if err != nil { + if errors.Is(err, favourites.ErrNotFound) { + http.Error(w, err.Error(), http.StatusNotFound) + } else { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 00000000..3459ba0a --- /dev/null +++ b/internal/httpapi/server_test.go @@ -0,0 +1,199 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/parath/platform-go-challenge/internal/favourites" +) + +// --- MockStore για testing error paths --- +type MockStore struct { + Err error +} + +func (m *MockStore) AddFavourite(favourites.Favourite) error { + return m.Err +} + +func (m *MockStore) GetFavourites(userID string) ([]favourites.Favourite, error) { + return nil, m.Err +} + +func (m *MockStore) UpdateFavourite(userID, favouriteID string, update favourites.Favourite) (favourites.Favourite, error) { + return favourites.Favourite{}, m.Err +} + +func (m *MockStore) DeleteFavourite(userID, favouriteID string) error { + return m.Err +} + +func (m *MockStore) NextFavouriteID() string { + return "fav-mock" +} + +// --- Helpers for integration tests --- +func newTestServer() *mux.Router { + return NewServer(favourites.NewInMemoryStore()) +} + +func doRequest(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != nil { + switch b := body.(type) { + case []byte: + reader = bytes.NewReader(b) + case string: + reader = bytes.NewBufferString(b) + default: + data, err := json.Marshal(b) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + reader = bytes.NewReader(data) + } + } + req := httptest.NewRequest(method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + return rr +} + +func createFavourite(t *testing.T, r http.Handler, userID string, body map[string]any) favourites.Favourite { + t.Helper() + rr := doRequest(t, r, http.MethodPost, "/favourites/"+userID, body) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created favourites.Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("invalid json: %v", err) + } + return created +} + +// --- Tests --- +func TestGetFavourites_Empty(t *testing.T) { + r := newTestServer() + rr := doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var favs []favourites.Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &favs); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(favs) != 0 { + t.Fatalf("expected empty list, got %d", len(favs)) + } +} + +func TestPostFavourite_CreateAndList(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + "metadata": map[string]any{"title": "Sales Q4"}, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var created favourites.Favourite + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("invalid json: %v", err) + } + if created.ID == "" || created.UserID != "user-1" { + t.Fatalf("unexpected created favourite: %+v", created) + } + // List + rr = doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestPutFavourite_Update(t *testing.T) { + r := newTestServer() + created := createFavourite(t, r, "user-1", map[string]any{ + "assetId": "chart-42", + "assetType": "chart", + "description": "Top sales", + }) + + upd := favourites.Favourite{ + AssetID: "chart-99", + AssetType: "chart", + Description: "Updated", + CreatedAt: time.Now(), + } + path := "/favourites/" + created.UserID + "/" + created.ID + rr := doRequest(t, r, http.MethodPut, path, upd) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestPutFavourite_NotFound(t *testing.T) { + r := newTestServer() + upd := map[string]any{"assetId": "chart-1"} + rr := doRequest(t, r, http.MethodPut, "/favourites/user-1/fav-999", upd) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestDeleteFavourite(t *testing.T) { + r := newTestServer() + created := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1"}) + rr := doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rr.Code) + } + // Delete again -> 404 + rr = doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestPostFavourite_InvalidBody(t *testing.T) { + r := newTestServer() + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", "not-json") + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +// --- Error path tests with MockStore --- +func TestGetFavourites_StoreError(t *testing.T) { + badStore := &MockStore{Err: errors.New("boom")} + r := NewServer(badStore) + + rr := doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rr.Code) + } +} + +func TestAddFavourite_StoreError(t *testing.T) { + badStore := &MockStore{Err: errors.New("boom")} + r := NewServer(badStore) + payload := map[string]any{"assetId": "a1"} + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rr.Code) + } +} diff --git a/main.go b/main.go index f8262406..dd06afb8 100644 --- a/main.go +++ b/main.go @@ -4,160 +4,15 @@ Favourites is a simple web server that manages a per-user list of favourite asse package main import ( - "encoding/json" - "fmt" "log" "net/http" - "sync" - "time" - "github.com/gorilla/mux" + "github.com/parath/platform-go-challenge/internal/favourites" + "github.com/parath/platform-go-challenge/internal/httpapi" ) -// Favourite represents a user's favourite asset. -// Common attributes (ID, UserID, AssetID, AssetType, Description, CreatedAt) -// are top-level fields. Asset-specific data (like chart axes or audience criteria) -// is stored as free-form JSON in Metadata for flexibility. -type Favourite struct { - ID string - UserID string - AssetID string - AssetType string - Description string - Metadata map[string]interface{} - CreatedAt time.Time -} - -// InMemoryStore stores favourites in memory per user -type InMemoryStore struct { - mu sync.RWMutex - store map[string][]Favourite - nextID int -} - -func NewInMemoryStore() *InMemoryStore { - return &InMemoryStore{store: make(map[string][]Favourite)} -} - -func (s *InMemoryStore) AddFavourite(f Favourite) { - s.mu.Lock() - defer s.mu.Unlock() - s.store[f.UserID] = append(s.store[f.UserID], f) -} - -func (s *InMemoryStore) GetFavourites(userID string) []Favourite { - s.mu.RLock() - defer s.mu.RUnlock() - return append([]Favourite(nil), s.store[userID]...) -} - -func (s *InMemoryStore) UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, bool) { - s.mu.Lock() - defer s.mu.Unlock() - list := s.store[userID] - for i := range list { - if list[i].ID == favouriteID { - // Update only mutable fields on the existing entry; keep immutable ones - existing := list[i] - existing.AssetID = update.AssetID - existing.AssetType = update.AssetType - existing.Description = update.Description - existing.Metadata = update.Metadata - list[i] = existing - s.store[userID] = list - return existing, true - } - } - return Favourite{}, false -} - -func (s *InMemoryStore) DeleteFavourite(userID, favouriteID string) bool { - s.mu.Lock() - defer s.mu.Unlock() - list := s.store[userID] - for i := range list { - if list[i].ID == favouriteID { - list[i] = list[len(list)-1] - list = list[:len(list)-1] - s.store[userID] = list - return true - } - } - return false -} - -func (s *InMemoryStore) NextFavouriteID() string { - s.mu.Lock() - defer s.mu.Unlock() - s.nextID++ - return fmt.Sprintf("fav-%d", s.nextID) -} - -var store = NewInMemoryStore() - -// Handlers -func getFavouritesHandler(w http.ResponseWriter, r *http.Request) { - userID := mux.Vars(r)["userId"] - favs := store.GetFavourites(userID) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(favs) -} - -func addFavouriteHandler(w http.ResponseWriter, r *http.Request) { - userID := mux.Vars(r)["userId"] - var f Favourite - if err := json.NewDecoder(r.Body).Decode(&f); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - f.UserID = userID - f.ID = store.NextFavouriteID() - f.CreatedAt = time.Now() - store.AddFavourite(f) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(f) -} - -func updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := vars["userId"] - favID := vars["id"] - var upd Favourite - if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - updated, ok := store.UpdateFavourite(userID, favID, upd) - if !ok { - http.Error(w, "favourite not found", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(updated) -} - -func deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - userID := vars["userId"] - favID := vars["id"] - if ok := store.DeleteFavourite(userID, favID); !ok { - http.Error(w, "favourite not found", http.StatusNotFound) - return - } - w.WriteHeader(http.StatusNoContent) -} - func main() { - r := mux.NewRouter() - r.HandleFunc("/favourites/{userId}", getFavouritesHandler).Methods("GET") - r.HandleFunc("/favourites/{userId}", addFavouriteHandler).Methods("POST") - r.HandleFunc("/favourites/{userId}/{id}", updateFavouriteHandler).Methods("PUT", "PATCH") - r.HandleFunc("/favourites/{userId}/{id}", deleteFavouriteHandler).Methods("DELETE") - + r := httpapi.NewServer(favourites.NewInMemoryStore()) log.Println("Server listening on :8080") - if err := http.ListenAndServe(":8080", r); err != nil { - log.Fatal(err) - } + log.Fatal(http.ListenAndServe(":8080", r)) } diff --git a/main_test.go b/main_test.go index f3b7f9a4..8611ad06 100644 --- a/main_test.go +++ b/main_test.go @@ -1,181 +1,26 @@ package main import ( - "bytes" - "encoding/json" - "io" "net/http" "net/http/httptest" "testing" - "github.com/gorilla/mux" + "github.com/parath/platform-go-challenge/internal/favourites" + "github.com/parath/platform-go-challenge/internal/httpapi" ) -// newTestServer creates a router with handlers and resets the global store for isolation -func newTestServer() *mux.Router { - store = NewInMemoryStore() - r := mux.NewRouter() - r.HandleFunc("/favourites/{userId}", getFavouritesHandler).Methods("GET") - r.HandleFunc("/favourites/{userId}", addFavouriteHandler).Methods("POST") - r.HandleFunc("/favourites/{userId}/{id}", updateFavouriteHandler).Methods("PUT", "PATCH") - r.HandleFunc("/favourites/{userId}/{id}", deleteFavouriteHandler).Methods("DELETE") - return r -} +// Smoke test: verify that the main server wiring works +func TestMainServerWiring(t *testing.T) { + router := httpapi.NewServer(favourites.NewInMemoryStore()) -func doRequest(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { - t.Helper() - var reader io.Reader - if body != nil { - switch b := body.(type) { - case []byte: - reader = bytes.NewReader(b) - case string: - reader = bytes.NewBufferString(b) - default: - data, err := json.Marshal(b) - if err != nil { - t.Fatalf("failed to marshal body: %v", err) - } - reader = bytes.NewReader(data) - } - } - req := httptest.NewRequest(method, path, reader) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } + req := httptest.NewRequest(http.MethodGet, "/favourites/smoke", nil) rr := httptest.NewRecorder() - r.ServeHTTP(rr, req) - return rr -} - -func TestGetFavourites_Empty(t *testing.T) { - r := newTestServer() - rr := doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - var favs []Favourite - if err := json.Unmarshal(rr.Body.Bytes(), &favs); err != nil { - t.Fatalf("invalid json: %v", err) - } - if len(favs) != 0 { - t.Fatalf("expected empty list, got %d", len(favs)) - } -} - -func TestPostFavourite_CreateAndList(t *testing.T) { - r := newTestServer() - // Create - payload := map[string]any{ - "assetId": "chart-42", - "assetType": "chart", - "description": "Top sales", - "metadata": map[string]any{"title": "Sales Q4"}, - } - rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) - if rr.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) - } - var created Favourite - if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { - t.Fatalf("invalid json: %v", err) - } - if created.ID == "" || created.UserID != "user-1" || created.AssetID != "chart-42" { - t.Fatalf("unexpected created favourite: %+v", created) - } - // List - rr = doRequest(t, r, http.MethodGet, "/favourites/user-1", nil) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - var favs []Favourite - if err := json.Unmarshal(rr.Body.Bytes(), &favs); err != nil { - t.Fatalf("invalid json: %v", err) - } - if len(favs) != 1 || favs[0].ID != created.ID { - t.Fatalf("expected one favourite matching created, got: %+v", favs) - } -} -func TestPutFavourite_UpdatePreservesImmutable(t *testing.T) { - r := newTestServer() - // Create first - created := createFavourite(t, r, "user-1", map[string]any{ - "assetId": "chart-42", - "assetType": "chart", - "description": "Top sales", - "metadata": map[string]any{"title": "Sales Q4"}, - }) + router.ServeHTTP(rr, req) - // Attempt update changing id/user/createdAt (should be ignored/preserved) - upd := Favourite{ - ID: "should-not-change", - UserID: "other-user", - AssetID: "chart-99", - AssetType: "chart", - Description: "Updated", - Metadata: map[string]any{"title": "New"}, - CreatedAt: created.CreatedAt.Add(24 * 60 * 60 * 1e9), - } - path := "/favourites/" + created.UserID + "/" + created.ID - rr := doRequest(t, r, http.MethodPut, path, upd) + // We expect either 200 (empty list) or 500 if store fails, + // but never a panic or crash. if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - var got Favourite - if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { - t.Fatalf("invalid json: %v", err) - } - if got.ID != created.ID || got.UserID != created.UserID || !got.CreatedAt.Equal(created.CreatedAt) { - t.Fatalf("immutable fields changed: before=%+v after=%+v", created, got) - } - if got.AssetID != "chart-99" || got.Description != "Updated" { - t.Fatalf("mutable fields not updated: %+v", got) - } -} - -func TestPutFavourite_NotFound(t *testing.T) { - r := newTestServer() - upd := map[string]any{"assetId": "chart-1"} - rr := doRequest(t, r, http.MethodPut, "/favourites/user-1/fav-999", upd) - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", rr.Code) - } -} - -func TestDeleteFavourite(t *testing.T) { - r := newTestServer() - created := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1"}) - // Delete existing - rr := doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) - if rr.Code != http.StatusNoContent { - t.Fatalf("expected 204, got %d", rr.Code) - } - // Delete again -> 404 - rr = doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", rr.Code) - } -} - -func TestPostFavourite_InvalidBody(t *testing.T) { - r := newTestServer() - rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", "not-json") - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", rr.Code) - } -} - -// helper to create a favourite and parse response -func createFavourite(t *testing.T, r http.Handler, userID string, body map[string]any) Favourite { - t.Helper() - rr := doRequest(t, r, http.MethodPost, "/favourites/"+userID, body) - if rr.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) - } - var created Favourite - if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { - t.Fatalf("invalid json: %v", err) + t.Fatalf("expected 200 OK for empty user, got %d", rr.Code) } - return created } From 30f23265f5667288c8e48f776cdae296fbf9e729 Mon Sep 17 00:00:00 2001 From: parath Date: Mon, 15 Sep 2025 13:13:01 +0300 Subject: [PATCH 3/9] adds JSON tags and creates a simple error model --- README.md | 48 +++++++++++++++++++++--------- internal/httpapi/server.go | 52 +++++++++++++++++---------------- internal/httpapi/server_test.go | 28 +++++++++++++++++- 3 files changed, 89 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ef3353a7..24e99d38 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ Run them with: go test ./... ``` -## Usage ecamples -Add a favourite +## Usage examples +- Add a favourite ```bash curl -X POST -H "Content-Type: application/json" \ - -d '{"assetId":"chart-42", "assetType":"chart", "description":"Top sales", "metadata":{"title":"Sales Q4","axes":["month","revenue"]}}' \ + -d '{"assetId":"chart-42", "assetType":"chart", "description":"Top sales", "assetData":{"title":"Sales Q4","axes":["month","revenue"]}}' \ http://localhost:8080/favourites/user123 ``` `Response 201, with created entry including generated id` @@ -46,12 +46,12 @@ curl -X POST -H "Content-Type: application/json" \ "assetId": "chart-42", "assetType": "chart", "description": "Top sales", - "metadata": { "title": "Sales Q4", "axes": ["month","revenue"] }, + "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, "createdAt": "2025-09-11T17:18:53.9766385+03:00" } ``` -List of favourites per user +- List favourites for a user ```bash curl -s http://localhost:8080/favourites/user123 | jq ``` @@ -64,31 +64,53 @@ curl -s http://localhost:8080/favourites/user123 | jq "assetId": "chart-42", "assetType": "chart", "description": "Top sales", - "metadata": { "title": "Sales Q4", "axes": ["month","revenue"] }, + "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, "createdAt": "2025-09-11T17:18:53.9766385+03:00" } ] ``` -Update an asset in favourites +- Update a favourite ```bash curl -X PUT -H "Content-Type: application/json" \ - -d '{"assetId":"chart-21", "assetType":"chart", "description":"Sales frequency", "metadata":{"title":"Annual retention","axes":["month","invoice_count"]}}' \ + -d '{"assetId":"chart-21", "assetType":"chart", "description":"Sales frequency", "assetData":{"title":"Annual retention","axes":["month","invoice_count"]}}' \ http://localhost:8080/favourites/user123/fav-1 ``` -Delete a favourite +- Delete a favourite ```bash curl -X DELETE http://localhost:8080/favourites/user123/fav-1 ``` ## Assumptions - REST API endpoints to fetch, add, remove and update assets in favourites list -- JSON request/response +- JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt) - In-memory store for the challenge purposes -- Tests for GET, POST, PUT, PATCH and DELETE verbs and store functions +- Tests for GET, POST, PUT and DELETE verbs and store functions + +## Data model +- This service stores references to existing platform assets. The source of truth for assets lives upstream. +- `description` is a user-provided label for the favourite and may be empty. +- `assetData` stores the upstream asset JSON as-is (flexible); on updates, clients send the full updated asset JSON. The service does not validate against upstream schemas. +- On reads, the service returns stored favourites. If upstream assets are missing, clients are expected to avoid selecting them; future versions may exclude or flag missing assets when an upstream lookup is enabled. + +## CI/CD (suggested checks) +In a CI pipeline, you can run basic quality gates before building and deploying: + +```bash +# Format code +go fmt ./... + +# Static analysis +go vet ./... +staticcheck ./... + +# Run tests +go test ./... +``` ## Next steps -- Solution is base on in-memory store which makes storage ephemeral and works for single instace only. Persistent storage should be used, for instance Postgres JSONB, or adopt with platform-wide storage solution. -- Performance: caching per user with Redis since it is shown in the frontpage +- Solution is based on an in-memory store which makes storage ephemeral and works for a single instance only. Persistent storage should be used, for instance Postgres JSONB, or adopt a platform-wide storage solution. +- Coordinate upstream contracts for asset verification and potential reconciliation (exclusion/flags for missing assets) once teams align +- Performance: caching per user with Redis since it is shown on the front page - Performance: pagination for very large lists diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 806a38f3..56854c40 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -14,12 +14,27 @@ type Server struct { store favourites.Store } +type apiError struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, apiError{Error: msg}) +} + func NewServer(store favourites.Store) *mux.Router { s := &Server{store: store} r := mux.NewRouter() r.HandleFunc("/favourites/{userId}", s.getFavouritesHandler).Methods("GET") r.HandleFunc("/favourites/{userId}", s.addFavouriteHandler).Methods("POST") - r.HandleFunc("/favourites/{userId}/{id}", s.updateFavouriteHandler).Methods("PUT", "PATCH") + r.HandleFunc("/favourites/{userId}/{id}", s.updateFavouriteHandler).Methods("PUT") r.HandleFunc("/favourites/{userId}/{id}", s.deleteFavouriteHandler).Methods("DELETE") return r } @@ -29,22 +44,18 @@ func (s *Server) getFavouritesHandler(w http.ResponseWriter, r *http.Request) { userID := mux.Vars(r)["userId"] favs, err := s.store.GetFavourites(userID) if err != nil { - http.Error(w, "failed to get favourites", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "failed to get favourites") return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(favs); err != nil { - http.Error(w, "failed to encode response", http.StatusInternalServerError) - return - } + writeJSON(w, http.StatusOK, favs) } func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { userID := mux.Vars(r)["userId"] var f favourites.Favourite if err := json.NewDecoder(r.Body).Decode(&f); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "invalid request body") return } f.UserID = userID @@ -52,16 +63,11 @@ func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { f.CreatedAt = time.Now() err := s.store.AddFavourite(f) if err != nil { - http.Error(w, "failed to add favourite", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "failed to add favourite") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(f); err != nil { - http.Error(w, "failed to encode response", http.StatusInternalServerError) - return - } + writeJSON(w, http.StatusCreated, f) } func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { @@ -70,24 +76,20 @@ func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) favID := vars["id"] var upd favourites.Favourite if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "invalid request body") return } updated, err := s.store.UpdateFavourite(userID, favID, upd) if err != nil { if errors.Is(err, favourites.ErrNotFound) { - http.Error(w, err.Error(), http.StatusNotFound) + writeError(w, http.StatusNotFound, err.Error()) } else { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal server error") } return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(updated); err != nil { - http.Error(w, "failed to encode response", http.StatusInternalServerError) - return - } + writeJSON(w, http.StatusOK, updated) } func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) { @@ -97,9 +99,9 @@ func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) err := s.store.DeleteFavourite(userID, favID) if err != nil { if errors.Is(err, favourites.ErrNotFound) { - http.Error(w, err.Error(), http.StatusNotFound) + writeError(w, http.StatusNotFound, err.Error()) } else { - http.Error(w, "internal server error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal server error") } return } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 3459ba0a..c261b44b 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -105,7 +105,7 @@ func TestPostFavourite_CreateAndList(t *testing.T) { "assetId": "chart-42", "assetType": "chart", "description": "Top sales", - "metadata": map[string]any{"title": "Sales Q4"}, + "assetData": map[string]any{"title": "Sales Q4"}, } rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) if rr.Code != http.StatusCreated { @@ -153,6 +153,16 @@ func TestPutFavourite_NotFound(t *testing.T) { if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rr.Code) } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } + var errBody map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatalf("invalid json error body: %v", err) + } + if _, ok := errBody["error"]; !ok { + t.Fatalf("expected error field in body, got %v", errBody) + } } func TestDeleteFavourite(t *testing.T) { @@ -167,6 +177,9 @@ func TestDeleteFavourite(t *testing.T) { if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rr.Code) } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } } func TestPostFavourite_InvalidBody(t *testing.T) { @@ -175,6 +188,13 @@ func TestPostFavourite_InvalidBody(t *testing.T) { if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } + var errBody map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatalf("invalid json error body: %v", err) + } } // --- Error path tests with MockStore --- @@ -186,6 +206,9 @@ func TestGetFavourites_StoreError(t *testing.T) { if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } } func TestAddFavourite_StoreError(t *testing.T) { @@ -196,4 +219,7 @@ func TestAddFavourite_StoreError(t *testing.T) { if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %s", ct) + } } From 118e0e286efb29250a7164320559d7971aeb126e Mon Sep 17 00:00:00 2001 From: parath Date: Mon, 15 Sep 2025 14:42:31 +0300 Subject: [PATCH 4/9] adds logging and uses error model --- internal/httpapi/server.go | 98 ++++++++++++++++++++++++++++----- internal/httpapi/server_test.go | 80 +++++++++++++++++++-------- 2 files changed, 140 insertions(+), 38 deletions(-) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 56854c40..fcf0da50 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -3,6 +3,8 @@ package httpapi import ( "encoding/json" "errors" + "fmt" + "log" "net/http" "time" @@ -25,8 +27,56 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } -func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, apiError{Error: msg}) +// logRequest writes a simple, consistent log line for responses (success or error). +// extras is an optional string with additional context like "status=... code=... msg=... userId=... favId=...". +func logRequest(r *http.Request, extras string) { + if extras != "" { + log.Printf("request: %s %s %s", r.Method, r.URL.Path, extras) + return + } + log.Printf("request: %s %s", r.Method, r.URL.Path) +} + +func writeError(w http.ResponseWriter, r *http.Request, status int, msg string, code string) { + logRequest(r, fmt.Sprintf("status=%d code=%s msg=%s", status, code, msg)) + writeJSON(w, status, apiError{Error: msg, Code: code}) +} + +func writeOK(w http.ResponseWriter, r *http.Request, status int, v any, extras string) { + logRequest(r, fmt.Sprintf("status=%d ok %s", status, extras)) + writeJSON(w, status, v) +} + +// validation helpers +const ( + assetTypeChart = "chart" + assetTypeInsight = "insight" + assetTypeAudience = "audience" +) + +func isValidAssetType(t string) bool { + switch t { + case assetTypeChart, assetTypeInsight, assetTypeAudience: + return true + default: + return false + } +} + +func validateFavouriteInput(f favourites.Favourite) (string, string) { + if f.AssetID == "" { + return "assetId is required", "invalid_body" + } + if !isValidAssetType(string(f.AssetType)) { + return "invalid assetType", "invalid_body" + } + if len(f.AssetData) > 100*1024 { // 100KB safety cap + return "assetData too large", "invalid_body" + } + if len(f.Description) > 512 { + return "description too long", "invalid_body" + } + return "", "" } func NewServer(store favourites.Store) *mux.Router { @@ -44,30 +94,39 @@ func (s *Server) getFavouritesHandler(w http.ResponseWriter, r *http.Request) { userID := mux.Vars(r)["userId"] favs, err := s.store.GetFavourites(userID) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to get favourites") + writeError(w, r, http.StatusInternalServerError, "failed to get favourites", "store_error") return } - writeJSON(w, http.StatusOK, favs) + writeOK(w, r, http.StatusOK, favs, fmt.Sprintf("userId=%s", userID)) } func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { userID := mux.Vars(r)["userId"] var f favourites.Favourite if err := json.NewDecoder(r.Body).Decode(&f); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + writeError(w, r, http.StatusBadRequest, "invalid request body", "invalid_body") + return + } + if msg, code := validateFavouriteInput(f); msg != "" { + writeError(w, r, http.StatusBadRequest, msg, code) return } f.UserID = userID f.ID = s.store.NextFavouriteID() f.CreatedAt = time.Now() + f.UpdatedAt = f.CreatedAt err := s.store.AddFavourite(f) if err != nil { - writeError(w, http.StatusInternalServerError, "failed to add favourite") + if errors.Is(err, favourites.ErrConflict) { + writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", "conflict") + return + } + writeError(w, r, http.StatusInternalServerError, "failed to add favourite", "store_error") return } - writeJSON(w, http.StatusCreated, f) + writeOK(w, r, http.StatusCreated, f, fmt.Sprintf("userId=%s favId=%s", f.UserID, f.ID)) } func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { @@ -76,20 +135,29 @@ func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) favID := vars["id"] var upd favourites.Favourite if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") + writeError(w, r, http.StatusBadRequest, "invalid request body", "invalid_body") + return + } + if msg, code := validateFavouriteInput(upd); msg != "" { + writeError(w, r, http.StatusBadRequest, msg, code) return } + upd.UpdatedAt = time.Now() updated, err := s.store.UpdateFavourite(userID, favID, upd) if err != nil { if errors.Is(err, favourites.ErrNotFound) { - writeError(w, http.StatusNotFound, err.Error()) - } else { - writeError(w, http.StatusInternalServerError, "internal server error") + writeError(w, r, http.StatusNotFound, err.Error(), "not_found") + return + } + if errors.Is(err, favourites.ErrConflict) { + writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", "conflict") + return } + writeError(w, r, http.StatusInternalServerError, "internal server error", "store_error") return } - writeJSON(w, http.StatusOK, updated) + writeOK(w, r, http.StatusOK, updated, fmt.Sprintf("userId=%s favId=%s", userID, favID)) } func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) { @@ -99,12 +167,12 @@ func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) err := s.store.DeleteFavourite(userID, favID) if err != nil { if errors.Is(err, favourites.ErrNotFound) { - writeError(w, http.StatusNotFound, err.Error()) + writeError(w, r, http.StatusNotFound, err.Error(), "not_found") } else { - writeError(w, http.StatusInternalServerError, "internal server error") + writeError(w, r, http.StatusInternalServerError, "internal server error", "store_error") } return } - + logRequest(r, fmt.Sprintf("status=%d ok userId=%s favId=%s", http.StatusNoContent, userID, favID)) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index c261b44b..95ec6db2 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -14,35 +14,21 @@ import ( "github.com/parath/platform-go-challenge/internal/favourites" ) -// --- MockStore για testing error paths --- +// --- MockStore for testing error paths --- type MockStore struct { Err error } -func (m *MockStore) AddFavourite(favourites.Favourite) error { - return m.Err -} - -func (m *MockStore) GetFavourites(userID string) ([]favourites.Favourite, error) { - return nil, m.Err -} - +func (m *MockStore) AddFavourite(favourites.Favourite) error { return m.Err } +func (m *MockStore) GetFavourites(userID string) ([]favourites.Favourite, error) { return nil, m.Err } func (m *MockStore) UpdateFavourite(userID, favouriteID string, update favourites.Favourite) (favourites.Favourite, error) { return favourites.Favourite{}, m.Err } - -func (m *MockStore) DeleteFavourite(userID, favouriteID string) error { - return m.Err -} - -func (m *MockStore) NextFavouriteID() string { - return "fav-mock" -} +func (m *MockStore) DeleteFavourite(userID, favouriteID string) error { return m.Err } +func (m *MockStore) NextFavouriteID() string { return "fav-mock" } // --- Helpers for integration tests --- -func newTestServer() *mux.Router { - return NewServer(favourites.NewInMemoryStore()) -} +func newTestServer() *mux.Router { return NewServer(favourites.NewInMemoryStore()) } func doRequest(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { t.Helper() @@ -148,7 +134,7 @@ func TestPutFavourite_Update(t *testing.T) { func TestPutFavourite_NotFound(t *testing.T) { r := newTestServer() - upd := map[string]any{"assetId": "chart-1"} + upd := map[string]any{"assetId": "chart-1", "assetType": "chart"} rr := doRequest(t, r, http.MethodPut, "/favourites/user-1/fav-999", upd) if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rr.Code) @@ -167,7 +153,7 @@ func TestPutFavourite_NotFound(t *testing.T) { func TestDeleteFavourite(t *testing.T) { r := newTestServer() - created := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1"}) + created := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1", "assetType": "chart"}) rr := doRequest(t, r, http.MethodDelete, "/favourites/"+created.UserID+"/"+created.ID, nil) if rr.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d", rr.Code) @@ -214,7 +200,7 @@ func TestGetFavourites_StoreError(t *testing.T) { func TestAddFavourite_StoreError(t *testing.T) { badStore := &MockStore{Err: errors.New("boom")} r := NewServer(badStore) - payload := map[string]any{"assetId": "a1"} + payload := map[string]any{"assetId": "a1", "assetType": "chart"} rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) @@ -223,3 +209,51 @@ func TestAddFavourite_StoreError(t *testing.T) { t.Fatalf("expected application/json, got %s", ct) } } + +// --- Validation tests --- +func TestPostFavourite_Validation_MissingAssetId(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetType": "chart", + "assetData": map[string]any{"title": "X"}, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestPostFavourite_Validation_InvalidAssetType(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetId": "chart-1", + "assetType": "wrong", + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +// --- Conflict tests --- +func TestPostFavourite_DuplicateConflict(t *testing.T) { + r := newTestServer() + payload := map[string]any{"assetId": "a1", "assetType": "chart"} + _ = doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + +func TestPutFavourite_ChangeToExistingAsset_Conflict(t *testing.T) { + r := newTestServer() + fav1 := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1", "assetType": "chart"}) + _ = createFavourite(t, r, "user-1", map[string]any{"assetId": "a2", "assetType": "chart"}) + upd := favourites.Favourite{AssetID: "a2", AssetType: "chart"} + path := "/favourites/" + fav1.UserID + "/" + fav1.ID + rr := doRequest(t, r, http.MethodPut, path, upd) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} From bac59cd7f0d25ebaf030a673ab769431897df4a0 Mon Sep 17 00:00:00 2001 From: parath Date: Mon, 15 Sep 2025 15:12:57 +0300 Subject: [PATCH 5/9] adds error code type --- internal/httpapi/server.go | 45 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index fcf0da50..f41a92f6 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -16,9 +16,18 @@ type Server struct { store favourites.Store } +type ErrorCode string + +const ( + CodeInvalidBody ErrorCode = "invalid_body" + CodeStoreError ErrorCode = "store_error" + CodeNotFound ErrorCode = "not_found" + CodeConflict ErrorCode = "conflict" +) + type apiError struct { - Error string `json:"error"` - Code string `json:"code,omitempty"` + Error string `json:"error"` + Code ErrorCode `json:"code,omitempty"` } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -37,7 +46,7 @@ func logRequest(r *http.Request, extras string) { log.Printf("request: %s %s", r.Method, r.URL.Path) } -func writeError(w http.ResponseWriter, r *http.Request, status int, msg string, code string) { +func writeError(w http.ResponseWriter, r *http.Request, status int, msg string, code ErrorCode) { logRequest(r, fmt.Sprintf("status=%d code=%s msg=%s", status, code, msg)) writeJSON(w, status, apiError{Error: msg, Code: code}) } @@ -63,18 +72,20 @@ func isValidAssetType(t string) bool { } } -func validateFavouriteInput(f favourites.Favourite) (string, string) { +// This service stores a lightweight snapshot of the upstream asset, so we cap assetData +// around 100KB to prevent abuse. +func validateFavouriteInput(f favourites.Favourite) (string, ErrorCode) { if f.AssetID == "" { - return "assetId is required", "invalid_body" + return "assetId is required", CodeInvalidBody } if !isValidAssetType(string(f.AssetType)) { - return "invalid assetType", "invalid_body" + return "invalid assetType", CodeInvalidBody } - if len(f.AssetData) > 100*1024 { // 100KB safety cap - return "assetData too large", "invalid_body" + if len(f.AssetData) > 100*1024 { // ~100KB safety cap + return "assetData too large", CodeInvalidBody } if len(f.Description) > 512 { - return "description too long", "invalid_body" + return "description too long", CodeInvalidBody } return "", "" } @@ -94,7 +105,7 @@ func (s *Server) getFavouritesHandler(w http.ResponseWriter, r *http.Request) { userID := mux.Vars(r)["userId"] favs, err := s.store.GetFavourites(userID) if err != nil { - writeError(w, r, http.StatusInternalServerError, "failed to get favourites", "store_error") + writeError(w, r, http.StatusInternalServerError, "failed to get favourites", CodeStoreError) return } @@ -105,7 +116,7 @@ func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { userID := mux.Vars(r)["userId"] var f favourites.Favourite if err := json.NewDecoder(r.Body).Decode(&f); err != nil { - writeError(w, r, http.StatusBadRequest, "invalid request body", "invalid_body") + writeError(w, r, http.StatusBadRequest, "invalid request body", CodeInvalidBody) return } if msg, code := validateFavouriteInput(f); msg != "" { @@ -135,7 +146,7 @@ func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) favID := vars["id"] var upd favourites.Favourite if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { - writeError(w, r, http.StatusBadRequest, "invalid request body", "invalid_body") + writeError(w, r, http.StatusBadRequest, "invalid request body", CodeInvalidBody) return } if msg, code := validateFavouriteInput(upd); msg != "" { @@ -146,14 +157,14 @@ func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) updated, err := s.store.UpdateFavourite(userID, favID, upd) if err != nil { if errors.Is(err, favourites.ErrNotFound) { - writeError(w, r, http.StatusNotFound, err.Error(), "not_found") + writeError(w, r, http.StatusNotFound, err.Error(), CodeNotFound) return } if errors.Is(err, favourites.ErrConflict) { - writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", "conflict") + writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", CodeConflict) return } - writeError(w, r, http.StatusInternalServerError, "internal server error", "store_error") + writeError(w, r, http.StatusInternalServerError, "internal server error", CodeStoreError) return } @@ -167,9 +178,9 @@ func (s *Server) deleteFavouriteHandler(w http.ResponseWriter, r *http.Request) err := s.store.DeleteFavourite(userID, favID) if err != nil { if errors.Is(err, favourites.ErrNotFound) { - writeError(w, r, http.StatusNotFound, err.Error(), "not_found") + writeError(w, r, http.StatusNotFound, err.Error(), CodeNotFound) } else { - writeError(w, r, http.StatusInternalServerError, "internal server error", "store_error") + writeError(w, r, http.StatusInternalServerError, "internal server error", CodeStoreError) } return } From 073403ec3af0e0d563f3a30908bd655bfedb374e Mon Sep 17 00:00:00 2001 From: parath Date: Tue, 16 Sep 2025 11:38:01 +0300 Subject: [PATCH 6/9] moves update time to store and housekeeping --- internal/favourites/model.go | 29 +++++++++ internal/favourites/store.go | 104 ++++++++++++++++++++++++++++++ internal/favourites/store_test.go | 104 ++++++++++++++++++++++++++++++ internal/httpapi/server.go | 17 +---- internal/httpapi/server_test.go | 49 ++++++++++---- 5 files changed, 278 insertions(+), 25 deletions(-) create mode 100644 internal/favourites/model.go create mode 100644 internal/favourites/store.go create mode 100644 internal/favourites/store_test.go diff --git a/internal/favourites/model.go b/internal/favourites/model.go new file mode 100644 index 00000000..aaed42b9 --- /dev/null +++ b/internal/favourites/model.go @@ -0,0 +1,29 @@ +package favourites + +import ( + "encoding/json" + "time" +) + +type AssetType string + +const ( + AssetTypeChart AssetType = "chart" + AssetTypeInsight AssetType = "insight" + AssetTypeAudience AssetType = "audience" +) + +// Favourite represents a user's favourite asset. +// Common attributes (ID, UserID, AssetID, AssetType, Description, CreatedAt) +// are top-level fields. Asset-specific data (like chart axes or audience criteria) +// is stored as free-form JSON in AssetData for flexibility. +type Favourite struct { + ID string `json:"id"` + UserID string `json:"userId"` + AssetID string `json:"assetId"` + AssetType AssetType `json:"assetType"` + Description string `json:"description,omitempty"` + AssetData json.RawMessage `json:"assetData"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/internal/favourites/store.go b/internal/favourites/store.go new file mode 100644 index 00000000..07a3e509 --- /dev/null +++ b/internal/favourites/store.go @@ -0,0 +1,104 @@ +package favourites + +import ( + "errors" + "fmt" + "sync" + "time" +) + +// Store defines the behaviour for managing favourites. +type Store interface { + AddFavourite(Favourite) error + GetFavourites(userID string) ([]Favourite, error) + UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, error) + DeleteFavourite(userID, favouriteID string) error + NextFavouriteID() string +} + +// InMemoryStore stores favourites in memory per user +type InMemoryStore struct { + mu sync.RWMutex + store map[string][]Favourite + nextID int +} + +var ErrNotFound = errors.New("favourite not found") +var ErrConflict = errors.New("favourite already exists for user and asset") + +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{store: make(map[string][]Favourite)} +} + +func (s *InMemoryStore) AddFavourite(f Favourite) error { + s.mu.Lock() + defer s.mu.Unlock() + // Enforces uniqueness on (userId, assetId). + // NOTE: With a persistent DB backend (e.g., Postgres JSONB), this uniqueness + // would be enforced with a UNIQUE index on (userId, assetId). + for i := range s.store[f.UserID] { + if s.store[f.UserID][i].AssetID == f.AssetID { + return ErrConflict + } + } + f.CreatedAt = time.Now() + f.UpdatedAt = f.CreatedAt + s.store[f.UserID] = append(s.store[f.UserID], f) + return nil +} + +func (s *InMemoryStore) GetFavourites(userID string) ([]Favourite, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]Favourite(nil), s.store[userID]...), nil +} + +func (s *InMemoryStore) UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.store[userID] + for i := range list { + if list[i].ID == favouriteID { + existing := list[i] + // if assetId is changing, ensure no other fav uses that assetId for this user + if existing.AssetID != update.AssetID { + for j := range list { + if j != i && list[j].AssetID == update.AssetID { + return Favourite{}, ErrConflict + } + } + } + existing.AssetID = update.AssetID + existing.AssetType = update.AssetType + existing.Description = update.Description + existing.AssetData = update.AssetData + existing.UpdatedAt = time.Now() + list[i] = existing + s.store[userID] = list + return existing, nil + } + } + return Favourite{}, ErrNotFound +} + +func (s *InMemoryStore) DeleteFavourite(userID, favouriteID string) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.store[userID] + for i := range list { + if list[i].ID == favouriteID { + list[i] = list[len(list)-1] + list = list[:len(list)-1] + s.store[userID] = list + return nil + } + } + return ErrNotFound +} + +func (s *InMemoryStore) NextFavouriteID() string { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + return fmt.Sprintf("fav-%d", s.nextID) +} diff --git a/internal/favourites/store_test.go b/internal/favourites/store_test.go new file mode 100644 index 00000000..e54399b7 --- /dev/null +++ b/internal/favourites/store_test.go @@ -0,0 +1,104 @@ +package favourites + +import ( + "encoding/json" + "testing" + "time" +) + +func TestAddAndGetFavourites(t *testing.T) { + store := NewInMemoryStore() + + fav1 := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + AssetID: "chart-1", + AssetType: "chart", + Description: "First chart", + CreatedAt: time.Now(), + } + fav2 := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + AssetID: "chart-2", + AssetType: "chart", + Description: "Second chart", + CreatedAt: time.Now(), + } + + if err := store.AddFavourite(fav1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := store.AddFavourite(fav2); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + favs, err := store.GetFavourites("user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(favs) != 2 { + t.Fatalf("expected 2 favourites, got %d", len(favs)) + } +} + +func TestUpdateFavourite(t *testing.T) { + store := NewInMemoryStore() + fav := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + AssetID: "chart-1", + AssetType: "chart", + Description: "Old", + CreatedAt: time.Now(), + } + _ = store.AddFavourite(fav) + + update := Favourite{ + AssetID: "chart-99", + AssetType: "chart", + Description: "New", + AssetData: json.RawMessage(`{"title":"Updated"}`), + } + + updated, err := store.UpdateFavourite("user-1", fav.ID, update) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated.Description != "New" || updated.AssetID != "chart-99" { + t.Fatalf("update failed, got %+v", updated) + } +} + +func TestUpdateFavourite_NotFound(t *testing.T) { + store := NewInMemoryStore() + update := Favourite{AssetID: "chart-1"} + _, err := store.UpdateFavourite("user-1", "does-not-exist", update) + if err == nil { + t.Fatal("expected error, got nil") + } + if err != ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestDeleteFavourite(t *testing.T) { + store := NewInMemoryStore() + fav := Favourite{ + ID: store.NextFavouriteID(), + UserID: "user-1", + } + _ = store.AddFavourite(fav) + + if err := store.DeleteFavourite("user-1", fav.ID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Deleting again should fail + if err := store.DeleteFavourite("user-1", fav.ID); err == nil { + t.Fatal("expected error, got nil") + } + if err := store.DeleteFavourite("user-1", fav.ID); err != ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index f41a92f6..e56e6bad 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "net/http" - "time" "github.com/gorilla/mux" "github.com/parath/platform-go-challenge/internal/favourites" @@ -56,16 +55,9 @@ func writeOK(w http.ResponseWriter, r *http.Request, status int, v any, extras s writeJSON(w, status, v) } -// validation helpers -const ( - assetTypeChart = "chart" - assetTypeInsight = "insight" - assetTypeAudience = "audience" -) - -func isValidAssetType(t string) bool { +func isValidAssetType(t favourites.AssetType) bool { switch t { - case assetTypeChart, assetTypeInsight, assetTypeAudience: + case favourites.AssetTypeChart, favourites.AssetTypeInsight, favourites.AssetTypeAudience: return true default: return false @@ -78,7 +70,7 @@ func validateFavouriteInput(f favourites.Favourite) (string, ErrorCode) { if f.AssetID == "" { return "assetId is required", CodeInvalidBody } - if !isValidAssetType(string(f.AssetType)) { + if !isValidAssetType(f.AssetType) { return "invalid assetType", CodeInvalidBody } if len(f.AssetData) > 100*1024 { // ~100KB safety cap @@ -125,8 +117,6 @@ func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { } f.UserID = userID f.ID = s.store.NextFavouriteID() - f.CreatedAt = time.Now() - f.UpdatedAt = f.CreatedAt err := s.store.AddFavourite(f) if err != nil { if errors.Is(err, favourites.ErrConflict) { @@ -153,7 +143,6 @@ func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) writeError(w, r, http.StatusBadRequest, msg, code) return } - upd.UpdatedAt = time.Now() updated, err := s.store.UpdateFavourite(userID, favID, upd) if err != nil { if errors.Is(err, favourites.ErrNotFound) { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 95ec6db2..f23f8a63 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -210,6 +210,17 @@ func TestAddFavourite_StoreError(t *testing.T) { } } +// --- Conflict tests --- +func TestPostFavourite_DuplicateConflict(t *testing.T) { + r := newTestServer() + payload := map[string]any{"assetId": "a1", "assetType": "chart"} + _ = doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + // --- Validation tests --- func TestPostFavourite_Validation_MissingAssetId(t *testing.T) { r := newTestServer() @@ -235,17 +246,6 @@ func TestPostFavourite_Validation_InvalidAssetType(t *testing.T) { } } -// --- Conflict tests --- -func TestPostFavourite_DuplicateConflict(t *testing.T) { - r := newTestServer() - payload := map[string]any{"assetId": "a1", "assetType": "chart"} - _ = doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) - rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) - if rr.Code != http.StatusConflict { - t.Fatalf("expected 409, got %d", rr.Code) - } -} - func TestPutFavourite_ChangeToExistingAsset_Conflict(t *testing.T) { r := newTestServer() fav1 := createFavourite(t, r, "user-1", map[string]any{"assetId": "a1", "assetType": "chart"}) @@ -257,3 +257,30 @@ func TestPutFavourite_ChangeToExistingAsset_Conflict(t *testing.T) { t.Fatalf("expected 409, got %d", rr.Code) } } + +func TestPostFavourite_Validation_LongDescription(t *testing.T) { + r := newTestServer() + payload := map[string]any{ + "assetId": "chart-1", + "assetType": "chart", + "description": string(make([]byte, 600)), // >512 chars + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestPostFavourite_Validation_AssetDataTooLarge(t *testing.T) { + r := newTestServer() + huge := make([]byte, 200*1024) // 200KB > 100KB limit + payload := map[string]any{ + "assetId": "chart-2", + "assetType": "chart", + "assetData": huge, + } + rr := doRequest(t, r, http.MethodPost, "/favourites/user-1", payload) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} From 44d23cdeadeb66f3675cc9ca8c08717a598c955a Mon Sep 17 00:00:00 2001 From: parath Date: Tue, 16 Sep 2025 13:03:06 +0300 Subject: [PATCH 7/9] tiding up timestamps and tests --- README.md | 6 ++++-- internal/favourites/store.go | 10 ++++------ internal/favourites/store_test.go | 17 ++++++++--------- internal/httpapi/server.go | 8 ++++---- internal/httpapi/server_test.go | 4 +++- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 24e99d38..1a25e3ef 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ curl -X POST -H "Content-Type: application/json" \ "description": "Top sales", "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, "createdAt": "2025-09-11T17:18:53.9766385+03:00" + "updatedAt": "2025-09-11T17:18:53.9766385+03:00" } ``` @@ -66,6 +67,7 @@ curl -s http://localhost:8080/favourites/user123 | jq "description": "Top sales", "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, "createdAt": "2025-09-11T17:18:53.9766385+03:00" + "updatedAt": "2025-09-11T17:18:53.9766385+03:00" } ] ``` @@ -84,8 +86,8 @@ curl -X DELETE http://localhost:8080/favourites/user123/fav-1 ## Assumptions - REST API endpoints to fetch, add, remove and update assets in favourites list -- JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt) -- In-memory store for the challenge purposes +- JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt, updatedAt) +- In-memory store for the challenge purposes. No data store persistence present. Stored data are lost on restart. - Tests for GET, POST, PUT and DELETE verbs and store functions ## Data model diff --git a/internal/favourites/store.go b/internal/favourites/store.go index 07a3e509..f5614bc9 100644 --- a/internal/favourites/store.go +++ b/internal/favourites/store.go @@ -9,7 +9,7 @@ import ( // Store defines the behaviour for managing favourites. type Store interface { - AddFavourite(Favourite) error + AddFavourite(Favourite) (Favourite, error) GetFavourites(userID string) ([]Favourite, error) UpdateFavourite(userID, favouriteID string, update Favourite) (Favourite, error) DeleteFavourite(userID, favouriteID string) error @@ -30,21 +30,19 @@ func NewInMemoryStore() *InMemoryStore { return &InMemoryStore{store: make(map[string][]Favourite)} } -func (s *InMemoryStore) AddFavourite(f Favourite) error { +func (s *InMemoryStore) AddFavourite(f Favourite) (Favourite, error) { s.mu.Lock() defer s.mu.Unlock() // Enforces uniqueness on (userId, assetId). - // NOTE: With a persistent DB backend (e.g., Postgres JSONB), this uniqueness - // would be enforced with a UNIQUE index on (userId, assetId). for i := range s.store[f.UserID] { if s.store[f.UserID][i].AssetID == f.AssetID { - return ErrConflict + return Favourite{}, ErrConflict } } f.CreatedAt = time.Now() f.UpdatedAt = f.CreatedAt s.store[f.UserID] = append(s.store[f.UserID], f) - return nil + return f, nil } func (s *InMemoryStore) GetFavourites(userID string) ([]Favourite, error) { diff --git a/internal/favourites/store_test.go b/internal/favourites/store_test.go index e54399b7..5186267e 100644 --- a/internal/favourites/store_test.go +++ b/internal/favourites/store_test.go @@ -3,7 +3,6 @@ package favourites import ( "encoding/json" "testing" - "time" ) func TestAddAndGetFavourites(t *testing.T) { @@ -14,22 +13,22 @@ func TestAddAndGetFavourites(t *testing.T) { UserID: "user-1", AssetID: "chart-1", AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Chart-1"}`), Description: "First chart", - CreatedAt: time.Now(), } fav2 := Favourite{ ID: store.NextFavouriteID(), UserID: "user-1", AssetID: "chart-2", AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Chart-2"}`), Description: "Second chart", - CreatedAt: time.Now(), } - if err := store.AddFavourite(fav1); err != nil { + if _, err := store.AddFavourite(fav1); err != nil { t.Fatalf("unexpected error: %v", err) } - if err := store.AddFavourite(fav2); err != nil { + if _, err := store.AddFavourite(fav2); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -49,16 +48,16 @@ func TestUpdateFavourite(t *testing.T) { UserID: "user-1", AssetID: "chart-1", AssetType: "chart", + AssetData: json.RawMessage(`{"title":"Chart-1"}`), Description: "Old", - CreatedAt: time.Now(), } - _ = store.AddFavourite(fav) + _, _ = store.AddFavourite(fav) update := Favourite{ AssetID: "chart-99", AssetType: "chart", - Description: "New", AssetData: json.RawMessage(`{"title":"Updated"}`), + Description: "New", } updated, err := store.UpdateFavourite("user-1", fav.ID, update) @@ -88,7 +87,7 @@ func TestDeleteFavourite(t *testing.T) { ID: store.NextFavouriteID(), UserID: "user-1", } - _ = store.AddFavourite(fav) + _, _ = store.AddFavourite(fav) if err := store.DeleteFavourite("user-1", fav.ID); err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index e56e6bad..dad7511b 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -117,17 +117,17 @@ func (s *Server) addFavouriteHandler(w http.ResponseWriter, r *http.Request) { } f.UserID = userID f.ID = s.store.NextFavouriteID() - err := s.store.AddFavourite(f) + created, err := s.store.AddFavourite(f) if err != nil { if errors.Is(err, favourites.ErrConflict) { - writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", "conflict") + writeError(w, r, http.StatusConflict, "favourite already exists for user and asset", CodeConflict) return } - writeError(w, r, http.StatusInternalServerError, "failed to add favourite", "store_error") + writeError(w, r, http.StatusInternalServerError, "failed to add favourite", CodeStoreError) return } - writeOK(w, r, http.StatusCreated, f, fmt.Sprintf("userId=%s favId=%s", f.UserID, f.ID)) + writeOK(w, r, http.StatusCreated, created, fmt.Sprintf("userId=%s favId=%s", created.UserID, created.ID)) } func (s *Server) updateFavouriteHandler(w http.ResponseWriter, r *http.Request) { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index f23f8a63..e7708ac3 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -19,7 +19,9 @@ type MockStore struct { Err error } -func (m *MockStore) AddFavourite(favourites.Favourite) error { return m.Err } +func (m *MockStore) AddFavourite(favourites.Favourite) (favourites.Favourite, error) { + return favourites.Favourite{}, m.Err +} func (m *MockStore) GetFavourites(userID string) ([]favourites.Favourite, error) { return nil, m.Err } func (m *MockStore) UpdateFavourite(userID, favouriteID string, update favourites.Favourite) (favourites.Favourite, error) { return favourites.Favourite{}, m.Err From 31ac8cf0a616899f3bb01a48d83ce4dd7d929bb6 Mon Sep 17 00:00:00 2001 From: parath Date: Tue, 16 Sep 2025 13:23:50 +0300 Subject: [PATCH 8/9] consistency in README --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1a25e3ef..bd522f9a 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,12 @@ curl -X POST -H "Content-Type: application/json" \ ``` { "id": "fav-1", + "userId": "user123", "assetId": "chart-42", "assetType": "chart", "description": "Top sales", "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, - "createdAt": "2025-09-11T17:18:53.9766385+03:00" + "createdAt": "2025-09-11T17:18:53.9766385+03:00", "updatedAt": "2025-09-11T17:18:53.9766385+03:00" } ``` @@ -62,11 +63,12 @@ curl -s http://localhost:8080/favourites/user123 | jq [ { "id": "fav-1", + "userId": "user123", "assetId": "chart-42", "assetType": "chart", "description": "Top sales", "assetData": { "title": "Sales Q4", "axes": ["month","revenue"] }, - "createdAt": "2025-09-11T17:18:53.9766385+03:00" + "createdAt": "2025-09-11T17:18:53.9766385+03:00", "updatedAt": "2025-09-11T17:18:53.9766385+03:00" } ] @@ -88,7 +90,7 @@ curl -X DELETE http://localhost:8080/favourites/user123/fav-1 - REST API endpoints to fetch, add, remove and update assets in favourites list - JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt, updatedAt) - In-memory store for the challenge purposes. No data store persistence present. Stored data are lost on restart. -- Tests for GET, POST, PUT and DELETE verbs and store functions +- Tests cover store methods, HTTP handlers, validation, conflicts and error paths (including with a mock store). ## Data model - This service stores references to existing platform assets. The source of truth for assets lives upstream. From 771d5cd8b8212d6cdc2b29b6e4dc19c396311877 Mon Sep 17 00:00:00 2001 From: parath Date: Wed, 17 Sep 2025 09:10:21 +0300 Subject: [PATCH 9/9] adds README note --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bd522f9a..7616c6d0 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ curl -X DELETE http://localhost:8080/favourites/user123/fav-1 ``` ## Assumptions -- REST API endpoints to fetch, add, remove and update assets in favourites list -- JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt, updatedAt) +- REST API endpoints to fetch, add, remove and update assets in favourites list. +- JSON request/response with lower-camel JSON keys (id, userId, assetId, assetType, description, assetData, createdAt, updatedAt). - In-memory store for the challenge purposes. No data store persistence present. Stored data are lost on restart. - Tests cover store methods, HTTP handlers, validation, conflicts and error paths (including with a mock store). @@ -115,6 +115,7 @@ go test ./... ## Next steps - Solution is based on an in-memory store which makes storage ephemeral and works for a single instance only. Persistent storage should be used, for instance Postgres JSONB, or adopt a platform-wide storage solution. -- Coordinate upstream contracts for asset verification and potential reconciliation (exclusion/flags for missing assets) once teams align -- Performance: caching per user with Redis since it is shown on the front page -- Performance: pagination for very large lists +- Make use of request context for better efficiency, especially when a persistent store is integrated. +- Coordinate upstream contracts for asset verification and potential reconciliation (exclusion/flags for missing assets) once teams align. +- Performance: caching per user with Redis since it is shown on the front page. +- Performance: pagination for very large lists.