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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

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

👏

Copy link
Owner Author

Choose a reason for hiding this comment

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

Thanks! 🙏

RUN apk --no-cache add ca-certificates

WORKDIR /root/
COPY --from=builder /app/favourites .

EXPOSE 8080
CMD ["./favourites"]
126 changes: 108 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,121 @@
# 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 examples
- Add a favourite
```bash
curl -X POST -H "Content-Type: application/json" \
-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`

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",
"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",
"updatedAt": "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 favourites for a 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",
"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",
"updatedAt": "2025-09-11T17:18:53.9766385+03:00"
}
]
```

## Submission
- Update a favourite
```bash
curl -X PUT -H "Content-Type: application/json" \
-d '{"assetId":"chart-21", "assetType":"chart", "description":"Sales frequency", "assetData":{"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 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.

Choose a reason for hiding this comment

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

How would you model it in a database? What DB would you use and with what schema?

Copy link
Owner Author

Choose a reason for hiding this comment

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

For persistence, I would align with the platform’s existing DB solution.
Assuming Postgres, I would model it like:

CREATE TABLE favourites (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  asset_id TEXT NOT NULL,
  asset_type TEXT NOT NULL,
  description TEXT,
  asset_data JSONB NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  UNIQUE(user_id, asset_id)
);

JSONB gives flexibility for asset data while enabling indexing.
The unique (user_id, asset_id) constraint enforces the same rule as in the in-memory store.

- 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.
- `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 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.
- 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.
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/parath/platform-go-challenge

go 1.25

require github.com/gorilla/mux v1.8.1
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
29 changes: 29 additions & 0 deletions internal/favourites/model.go
Original file line number Diff line number Diff line change
@@ -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"`

Choose a reason for hiding this comment

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

What’s the reason behind opting for json.RawMessage type here instead of []byte ?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Great question! I chose json.RawMessage because it makes the intent explicit: the field holds JSON. It avoids extra (un)marshalling steps compared to []byte when embedding JSON blobs and integrates directly with Go’s encoding/json.

CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
102 changes: 102 additions & 0 deletions internal/favourites/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package favourites

import (
"errors"
"fmt"
"sync"
"time"
)

// Store defines the behaviour for managing favourites.
type Store interface {
AddFavourite(Favourite) (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

Choose a reason for hiding this comment

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

Great to see that you use a RW mutex 👏

Copy link
Owner Author

Choose a reason for hiding this comment

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

🙏 Thanks!

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) (Favourite, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Enforces uniqueness on (userId, assetId).
for i := range s.store[f.UserID] {
if s.store[f.UserID][i].AssetID == f.AssetID {
return Favourite{}, ErrConflict
}
}
f.CreatedAt = time.Now()
f.UpdatedAt = f.CreatedAt
s.store[f.UserID] = append(s.store[f.UserID], f)
return f, 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)
}
Loading