Skip to content

Commit 30134ec

Browse files
wip
1 parent 656420d commit 30134ec

File tree

6 files changed

+95
-117
lines changed

6 files changed

+95
-117
lines changed

client-challenge/readme.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ our team.
1313
This exercise has been designed to give a glimpse of what it is like to build a
1414
messaging app, and the kind of technical challenges we face and care about.
1515

16-
We expect you to spend 4-6 hours on this challenge, simulating real-world, time-boxed work.
16+
We expect you to spend 4-6 hours on this challenge, simulating real-world,
17+
time-boxed work.
1718

1819
## Instructions
1920

2021
You are tasked with the implementation of a messaging app that allows the user
2122
to communicate (send and receive text messages) with bots, each in their own 1:1
22-
chat. You can choose a target platform of your choice for this challenge: iOS,
23-
macOS, or web.
23+
chat.
2424

25-
A server is available for you to use. You can read more about it in
25+
You can choose a target platform of your choice for this challenge: iOS, macOS,
26+
or web.
27+
28+
A companion server is available for you to use. You can read more about it in
2629
[`./server`](./server). Its documentation contains information on how it can be
2730
run, and what kinds of API endpoints & entities are available.
2831

@@ -62,7 +65,7 @@ will have to prioritize what you work on. A few things that are important for us
6265
and that will be considered during the review:
6366
- **documentation**: is the readme clear? are important parts of the code
6467
documented?
65-
- **impact**: which features did you prioritize?
68+
- **impact**: what did you consciouslly decided to prioritize?
6669
- **maintainability**: is the code well-structured and easy to read/evolve?
6770
- **robustness**: is the code tested or easily testable? are edge-cases
6871
considered? is static analysis leveraged?

client-challenge/server/data.json

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
11
[
2-
"How did the doctor revive the developer?",
3-
"How did the web dev hurt Comic Sans feelings?",
4-
"How do you comfort a JavaScript bug?",
5-
"How do you make a Web App accessible?",
6-
"What do you call __proto__? Dunder proto. Michael Scott was the regional manager where?",
7-
"What does a React proposal mean?",
8-
"Why couldn’t the React component understand the joke?",
9-
"Why did Jason cover himself with bubble wrap?",
10-
"Why did the C# developer fall asleep?",
11-
"Why did the CoffeeScript developer keep getting lost?",
12-
"Why did the JavaScript boxer goto the chiropractor?",
13-
"Why did the React Higher Order Component give up?",
14-
"Why did the Web A11y Dev keep getting distracted?",
15-
"Why did the child component have such great self-esteem?",
16-
"Why did the developer go broke?",
17-
"Why did the functional component feel lost?",
18-
"Why did the react class component feel relieved?",
19-
"Why did the react developer have an addiction?",
20-
"Why did the software company hire drama majors from Starbucks?",
21-
"Why do C# and Java developers keep breaking their keyboards",
22-
"Why was Ember.js turning red?",
23-
"Why was the JavaScript developer sad?",
24-
"Why was the react developer late to everything?",
25-
"dev1 > What tool do you use to switch versions of node?"
2+
"I told my doctor that I broke my arm in two places – he told me to stop going to those places.",
3+
"This is your captain speaking, AND THIS IS YOUR CAPTAIN SHOUTING.",
4+
"Before you criticize someone, walk a mile in their shoes. That way, when you do criticize them, you're a mile away, and you have their shoes.",
5+
"I was at the park wondering why this frisbee kept getting bigger… and then it hit me.",
6+
"Two fish in a tank, one looks at the other and says, 'How do you drive this thing?'",
7+
"Evening news is where they begin with 'Good evening,' and then proceed to tell you why it isn’t.",
8+
"When I met my now wife, I asked if she was vegetarian because she really loved animals. She responded, 'No, I just really hate vegetables.'",
9+
"I know they say that money talks, but all mine says is 'Goodbye.'",
10+
"I have an inferiority complex, but it's not a very good one.",
11+
"What do you call a lazy kangaroo? A pouch potato.",
12+
"My wife and I laugh about how competitive we are. But I laugh more.",
13+
"My wife told me to stop impersonating a flamingo. I had to put my foot down.",
14+
"Have you heard about the guy who stole the calendar?! Well, he got 12 months!"
2615
]

client-challenge/server/go.mod

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ module github.com/hearthands/challenges/ios-engineer/server
33
go 1.22.5
44

55
require (
6-
github.com/go-chi/chi/v5 v5.1.0
6+
github.com/go-chi/chi/v5 v5.2.0
77
github.com/go-chi/render v1.0.3
8+
github.com/google/uuid v1.6.0
89
github.com/r3labs/sse/v2 v2.10.0
9-
golang.org/x/exp v0.0.0-20240707233637-46b078467d37
10+
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
1011
)
1112

1213
require (
1314
github.com/ajg/form v1.5.1 // indirect
14-
golang.org/x/net v0.27.0 // indirect
15+
golang.org/x/net v0.33.0 // indirect
1516
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
1617
)

client-challenge/server/go.sum

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
22
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
33
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
44
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5-
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
6-
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
5+
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
6+
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
77
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
88
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
9+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
911
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1012
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1113
github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0=
@@ -14,11 +16,11 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
1416
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
1517
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
1618
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
17-
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
18-
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
19+
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
20+
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
1921
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
20-
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
21-
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
22+
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
23+
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
2224
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
2325
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
2426
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=

client-challenge/server/main.go

Lines changed: 52 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,36 @@
11
package main
22

33
import (
4+
"cmp"
45
_ "embed"
56
"encoding/json"
67
"fmt"
78
"math/rand"
89
"net"
910
"net/http"
1011
"os"
11-
"strconv"
12-
"strings"
1312
"sync"
14-
"sync/atomic"
1513
"time"
1614

1715
"github.com/go-chi/chi/v5"
1816
"github.com/go-chi/chi/v5/middleware"
1917
"github.com/go-chi/render"
18+
"github.com/google/uuid"
2019
"github.com/r3labs/sse/v2"
2120
"golang.org/x/exp/maps"
2221
)
2322

23+
const (
24+
// maximum number of entities returned in a single request
25+
limit = 100
26+
)
27+
2428
var (
2529
// hostname used by the server
26-
hostname = envOrDefault("HTTP_HOST", "localhost")
30+
hostname = cmp.Or(os.Getenv("HTTP_HOST"), "localhost")
2731

2832
// port used by the server
29-
port = envOrDefault("PORT", "3000")
30-
31-
// maximum number of entities returned in a single request
32-
limit = 100
33+
port = cmp.Or(os.Getenv("PORT"), "3000")
3334
)
3435

3536
//go:embed data.json
@@ -43,22 +44,18 @@ func main() {
4344

4445
r.Use(middleware.Logger)
4546
r.Use(middleware.Recoverer)
46-
r.Use(middleware.Timeout(60 * time.Second))
47+
r.Use(middleware.Timeout(30 * time.Second))
4748

4849
r.Handle("/events", http.HandlerFunc(events.ServeHTTP))
4950

5051
r.Group(func(r chi.Router) {
5152
r.Use(render.SetContentType(render.ContentTypeJSON))
5253
r.Use(chaosMiddleware)
5354
r.Get("/chats", app.GetChats)
54-
r.Get("/chats/{chatID}/messages", app.GetMessages)
55-
r.Post("/chats/{chatID}/messages", app.PostMessages)
55+
r.Get("/chats/{chatID}/messages", app.GetChatMessages)
56+
r.Post("/chats/{chatID}/messages", app.PostChatMessages)
5657
})
5758

58-
r.Get("/livez", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59-
w.WriteHeader(http.StatusOK)
60-
}))
61-
6259
addr := net.JoinHostPort(hostname, port)
6360
fmt.Printf("Starting server on http://%s\n", addr)
6461
if err := http.ListenAndServe(addr, r); err != nil {
@@ -71,18 +68,19 @@ func main() {
7168
//
7269

7370
type Chat struct {
74-
ID uint32 `json:"id"`
75-
Name string `json:"name"`
71+
ID uuid.UUID `json:"id"`
72+
Name string `json:"name"`
7673

7774
messages []*Message
7875
}
7976

8077
type Message struct {
81-
ID uint32 `json:"id"`
82-
ChatID uint32 `json:"chat_id"`
83-
Author string `json:"author"`
84-
Text string `json:"text"`
85-
SentAt time.Time `json:"sent_at"`
78+
ID uuid.UUID `json:"id"`
79+
ChatID uuid.UUID `json:"chat_id"`
80+
Author string `json:"author"`
81+
Text string `json:"text"`
82+
SentAt time.Time `json:"sent_at"`
83+
IdempotencyKey string `json:"idempotency_key"`
8684
}
8785

8886
// App is both our controller and our data store. This coupling allows to keep
@@ -93,7 +91,7 @@ type App struct {
9391

9492
mu sync.RWMutex
9593
idempotencyKeys map[string]struct{} // store idempotency keys
96-
store map[uint32]*Chat // in-memory store of chats and messages
94+
store map[uuid.UUID]*Chat // in-memory store of chats and messages
9795
}
9896

9997
func NewApp(events *sse.Server) *App {
@@ -107,22 +105,21 @@ func NewApp(events *sse.Server) *App {
107105
now := time.Now()
108106

109107
// store contains all the chats, indexed by their ID
110-
store := map[uint32]*Chat{}
111-
for i, chat := range []*Chat{
112-
{Name: "John", messages: []*Message{
113-
{ID: newID(), Author: "bot", Text: "Sounds good 👍", SentAt: now},
108+
store := map[uuid.UUID]*Chat{}
109+
for _, chat := range []*Chat{
110+
{ID: uuid.New(), Name: "John", messages: []*Message{
111+
{ID: uuid.New(), Author: "bot", Text: "Sounds good 👍", SentAt: now, IdempotencyKey: uuid.New().String()},
114112
}},
115-
{Name: "Jessica", messages: []*Message{
116-
{ID: newID(), Author: "bot", Text: "How are you!?", SentAt: now.Add(-1 * time.Minute)},
113+
{ID: uuid.New(), Name: "Jessica", messages: []*Message{
114+
{ID: uuid.New(), Author: "bot", Text: "How are you!?", SentAt: now.Add(-1 * time.Minute), IdempotencyKey: uuid.New().String()},
117115
}},
118-
{Name: "Matt", messages: []*Message{
119-
{ID: newID(), Author: "bot", Text: "ok chat soon :)", SentAt: now.Add(-32 * time.Minute)},
116+
{ID: uuid.New(), Name: "Matt", messages: []*Message{
117+
{ID: uuid.New(), Author: "bot", Text: "ok chat soon :)", SentAt: now.Add(-32 * time.Minute), IdempotencyKey: uuid.New().String()},
120118
}},
121-
{Name: "Sarah", messages: []*Message{
122-
{ID: newID(), Author: "bot", Text: "ok talk later!", SentAt: now.Add(-24 * time.Hour)},
119+
{ID: uuid.New(), Name: "Sarah", messages: []*Message{
120+
{ID: uuid.New(), Author: "bot", Text: "ok talk later!", SentAt: now.Add(-24 * time.Hour), IdempotencyKey: uuid.New().String()},
123121
}},
124122
} {
125-
chat.ID = uint32(i + 1) // we don't want to have a chat id == 0
126123
for _, message := range chat.messages {
127124
message.ChatID = chat.ID
128125
}
@@ -147,7 +144,7 @@ func (app *App) GetChats(w http.ResponseWriter, r *http.Request) {
147144
render.JSON(w, r, chats)
148145
}
149146

150-
func (app *App) GetMessages(w http.ResponseWriter, r *http.Request) {
147+
func (app *App) GetChatMessages(w http.ResponseWriter, r *http.Request) {
151148
app.mu.RLock()
152149
defer app.mu.RUnlock()
153150

@@ -164,7 +161,7 @@ func (app *App) GetMessages(w http.ResponseWriter, r *http.Request) {
164161
render.JSON(w, r, chat.messages[start:end])
165162
}
166163

167-
func (app *App) PostMessages(w http.ResponseWriter, r *http.Request) {
164+
func (app *App) PostChatMessages(w http.ResponseWriter, r *http.Request) {
168165
app.mu.Lock()
169166
defer app.mu.Unlock()
170167

@@ -192,18 +189,21 @@ func (app *App) PostMessages(w http.ResponseWriter, r *http.Request) {
192189
}
193190

194191
newMessage := &Message{
195-
ID: newID(),
196-
ChatID: chat.ID,
197-
Author: "user",
198-
Text: message.Text,
199-
SentAt: time.Now(),
192+
ID: uuid.New(),
193+
ChatID: chat.ID,
194+
Author: "user",
195+
Text: message.Text,
196+
SentAt: time.Now(),
197+
IdempotencyKey: idempotencyKey,
200198
}
201199

202200
chat.messages = append(chat.messages, newMessage)
203201
app.publishEvent("messages", newMessage)
204-
app.idempotencyKeys[idempotencyKey] = struct{}{}
202+
205203
go app.sendDelayedAnswer(chat)
206204

205+
app.idempotencyKeys[idempotencyKey] = struct{}{}
206+
207207
w.WriteHeader(http.StatusOK)
208208
render.JSON(w, r, newMessage)
209209
}
@@ -217,26 +217,27 @@ func (app *App) sendDelayedAnswer(chat *Chat) {
217217
text := app.jokes[rand.Intn(len(app.jokes))]
218218

219219
newMessage := &Message{
220-
ID: newID(),
221-
ChatID: chat.ID,
222-
Author: "bot",
223-
Text: text,
224-
SentAt: time.Now(),
220+
ID: uuid.New(),
221+
ChatID: chat.ID,
222+
Author: "bot",
223+
Text: text,
224+
SentAt: time.Now(),
225+
IdempotencyKey: uuid.New().String(),
225226
}
226227

227228
chat.messages = append(chat.messages, newMessage)
228229
app.publishEvent("messages", newMessage)
229230
}
230231

231232
func (app *App) findChatByID(rawID string) (*Chat, error) {
232-
chatID, err := strconv.ParseUint(rawID, 10, 32)
233+
parsedID, err := uuid.Parse(rawID)
233234
if err != nil {
234235
return nil, fmt.Errorf("invalid chat ID %s: %w", rawID, err)
235236
}
236237

237-
chat, ok := app.store[uint32(chatID)]
238+
chat, ok := app.store[parsedID]
238239
if !ok {
239-
return nil, fmt.Errorf("chat %s not found", rawID)
240+
return nil, fmt.Errorf("chat not found with ID %s", rawID)
240241
}
241242

242243
return chat, nil
@@ -253,28 +254,6 @@ func (app *App) publishEvent(stream string, payload any) {
253254
}
254255
}
255256

256-
//
257-
// Helpers
258-
//
259-
260-
// lastID is the last ID generated by newID. It starts counting from the current
261-
// timestamp in seconds.
262-
var lastID = uint32(time.Now().Unix())
263-
264-
// newID generates a new ID, unique for the lifetime of this server.
265-
func newID() uint32 {
266-
return atomic.AddUint32(&lastID, 1)
267-
}
268-
269-
// envOrDefault returns the value of the environment variable at the given key.
270-
// Fallbacks to the given default if the value found is missing or empty.
271-
func envOrDefault(key string, defaultValue string) string {
272-
if v := strings.TrimSpace(os.Getenv(key)); len(v) > 0 {
273-
return v
274-
}
275-
return defaultValue
276-
}
277-
278257
// chaosMiddleware makes sure to randomly return errors and adds artificial
279258
// latency to simulate a real world system.
280259
func chaosMiddleware(next http.Handler) http.Handler {
@@ -288,7 +267,7 @@ func chaosMiddleware(next http.Handler) http.Handler {
288267
return
289268
}
290269

291-
// 5% chance of timeout
270+
// 5% chance of timeout after 5 seconds
292271
if rand.Intn(100) < 5 {
293272
<-time.After(5 * time.Second)
294273
http.Error(w, "timeout", http.StatusGatewayTimeout)

0 commit comments

Comments
 (0)