Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,14 @@ Pass the booking date and WeWork name as query parameters:
curl -X POST 'http://localhost:8080/api/book?date=Feb%2018,%202025&wework=115%20Broadway'
```

To book multiple dates at the same WeWork location, use the batch endpoint:

```bash
curl -X POST 'http://localhost:8080/api/book/batch' \
-H 'Content-Type: application/json' \
-d '{"wework":"115 Broadway","dates":["Feb 18, 2025","Feb 19, 2025"]}'
```

Batch bookings run in parallel, up to 3 at a time.

Use the exact WeWork name when possible. Partial names are accepted only when they match a single location.
164 changes: 164 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sync"
"time"

"github.com/eko/gocache/lib/v4/cache"
Expand Down Expand Up @@ -69,6 +71,168 @@ func registerBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cache[[]
}
}

type batchBookRequest struct {
Wework string `json:"wework"`
Dates []string `json:"dates"`
}

type batchBookResult struct {
Date string `json:"date"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}

type batchBookResponse struct {
Wework string `json:"wework"`
SuccessCount int `json:"successCount"`
FailureCount int `json:"failureCount"`
Summary string `json:"summary"`
Results []batchBookResult `json:"results"`
}

func registerBatchBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cache[[]byte]) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

var payload batchBookRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}

if payload.Wework == "" {
http.Error(w, "Missing 'wework' field", http.StatusBadRequest)
return
}

dates, parsedDates, err := normalizeBatchDates(payload.Dates)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

taskCtx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()

log.Println("Received batch booking request for", len(dates), "dates at", payload.Wework)

bearerToken, weworkLocation, err := prepareBooking(taskCtx, auth, payload.Wework, cacheManager)
if err != nil {
if errors.Is(err, ErrWeWorkLocationNotFound) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

results := runBatchBookings(taskCtx, bearerToken, weworkLocation, dates, parsedDates, 3)
response := newBatchBookResponse(payload.Wework, results)

w.Header().Set("Content-Type", "application/json; charset=utf-8")
if hasBatchBookingError(results) {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}

json.NewEncoder(w).Encode(response)
}
}

func newBatchBookResponse(wework string, results []batchBookResult) batchBookResponse {
response := batchBookResponse{Wework: wework, Results: results}
for _, result := range results {
if result.Status == "success" {
response.SuccessCount++
} else {
response.FailureCount++
}
}

total := response.SuccessCount + response.FailureCount
if response.FailureCount == 0 {
response.Summary = fmt.Sprintf("Successfully booked all %d dates at %s.", total, wework)
} else if response.SuccessCount == 0 {
response.Summary = fmt.Sprintf("Could not book any of the %d dates at %s.", total, wework)
} else {
response.Summary = fmt.Sprintf("Booked %d of %d dates at %s; %d failed.", response.SuccessCount, total, wework, response.FailureCount)
}
return response
}

func normalizeBatchDates(input []string) ([]string, []time.Time, error) {
if len(input) == 0 {
return nil, nil, errors.New("Missing 'dates' field")
}

dates := make([]string, 0, len(input))
parsedDates := make([]time.Time, 0, len(input))
seen := make(map[string]struct{}, len(input))
for _, date := range input {
dateString, err := reformatDate(date)
if err != nil {
return nil, nil, fmt.Errorf("invalid date %q. Expected format: 'Feb 18, 2025'", date)
}
if _, ok := seen[dateString]; ok {
return nil, nil, fmt.Errorf("duplicate date %q", dateString)
}
seen[dateString] = struct{}{}

parsedDate, err := parseBookingDate(dateString)
if err != nil {
return nil, nil, err
}

dates = append(dates, dateString)
parsedDates = append(parsedDates, parsedDate)
}

return dates, parsedDates, nil
}

func runBatchBookings(ctx context.Context, token string, location WeWorkLocation, dates []string, parsedDates []time.Time, limit int) []batchBookResult {
if limit <= 0 {
limit = 1
}

results := make([]batchBookResult, len(dates))
sem := make(chan struct{}, limit)
var wg sync.WaitGroup

for i := range dates {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()

results[i] = batchBookResult{Date: dates[i], Status: "success"}
if err := makeBookingRequestFunc(ctx, token, parsedDates[i], location); err != nil {
results[i].Status = "error"
results[i].Error = err.Error()
}
}(i)
}

wg.Wait()
return results
}

func hasBatchBookingError(results []batchBookResult) bool {
for _, result := range results {
if result.Status == "error" {
return true
}
}

return false
}

// reformatDate validates the date string against the format "Feb 18, 2025"
func reformatDate(date string) (string, error) {
const layout = "Jan 2, 2006"
Expand Down
120 changes: 119 additions & 1 deletion api_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import "testing"
import (
"context"
"testing"
"time"
)

func TestReformatDate(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -31,3 +35,117 @@ func TestReformatDate(t *testing.T) {
}
}
}

func TestNormalizeBatchDates(t *testing.T) {
dates, parsedDates, err := normalizeBatchDates([]string{"Mar 03, 2026", "Mar 4, 2026"})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

if len(dates) != 2 || len(parsedDates) != 2 {
t.Fatalf("Expected 2 dates, got %d and %d", len(dates), len(parsedDates))
}

if dates[0] != "Mar 3, 2026" {
t.Errorf("Expected reformatted first date, got %s", dates[0])
}
}

func TestNormalizeBatchDatesRejectsEmptyDates(t *testing.T) {
if _, _, err := normalizeBatchDates(nil); err == nil {
t.Errorf("Expected error for missing dates")
}
}

func TestNormalizeBatchDatesRejectsDuplicateDates(t *testing.T) {
if _, _, err := normalizeBatchDates([]string{"Mar 03, 2026", "Mar 3, 2026"}); err == nil {
t.Errorf("Expected error for duplicate dates")
}
}

func TestNewBatchBookResponseIncludesCountsAndSummary(t *testing.T) {
response := newBatchBookResponse("Coeur Marais", []batchBookResult{
{Date: "Mar 1, 2026", Status: "success"},
{Date: "Mar 2, 2026", Status: "error", Error: "failed"},
{Date: "Mar 3, 2026", Status: "success"},
})

if response.SuccessCount != 2 {
t.Errorf("Expected 2 successes, got %d", response.SuccessCount)
}
if response.FailureCount != 1 {
t.Errorf("Expected 1 failure, got %d", response.FailureCount)
}
if response.Summary != "Booked 2 of 3 dates at Coeur Marais; 1 failed." {
t.Errorf("Unexpected summary: %s", response.Summary)
}
}

func TestNewBatchBookResponseSummarizesFullSuccess(t *testing.T) {
response := newBatchBookResponse("Coeur Marais", []batchBookResult{
{Date: "Mar 1, 2026", Status: "success"},
{Date: "Mar 2, 2026", Status: "success"},
})

if response.Summary != "Successfully booked all 2 dates at Coeur Marais." {
t.Errorf("Unexpected summary: %s", response.Summary)
}
}

func TestNewBatchBookResponseSummarizesFullFailure(t *testing.T) {
response := newBatchBookResponse("Coeur Marais", []batchBookResult{
{Date: "Mar 1, 2026", Status: "error", Error: "failed"},
{Date: "Mar 2, 2026", Status: "error", Error: "failed"},
})

if response.Summary != "Could not book any of the 2 dates at Coeur Marais." {
t.Errorf("Unexpected summary: %s", response.Summary)
}
}

func TestRunBatchBookingsLimitsConcurrency(t *testing.T) {
original := makeBookingRequestFunc
defer func() { makeBookingRequestFunc = original }()

started := make(chan struct{}, 10)
block := make(chan struct{})
makeBookingRequestFunc = func(context.Context, string, time.Time, WeWorkLocation) error {
started <- struct{}{}
<-block
return nil
}

dates := []string{"Mar 1, 2026", "Mar 2, 2026", "Mar 3, 2026", "Mar 4, 2026", "Mar 5, 2026"}
parsedDates := make([]time.Time, len(dates))
done := make(chan []batchBookResult, 1)
go func() {
done <- runBatchBookings(context.Background(), "token", WeWorkLocation{}, dates, parsedDates, 3)
}()

for i := 0; i < 3; i++ {
select {
case <-started:
case <-time.After(time.Second):
t.Fatalf("Timed out waiting for booking %d to start", i+1)
}
}

select {
case <-started:
t.Fatalf("Started more than 3 concurrent bookings")
case <-time.After(50 * time.Millisecond):
}

close(block)

results := <-done
if len(results) != len(dates) {
t.Fatalf("Expected %d results, got %d", len(dates), len(results))
}

for _, result := range results {
if result.Status != "success" {
t.Errorf("Expected success result, got %+v", result)
}
}
}
26 changes: 22 additions & 4 deletions browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,34 @@ func getWeWorkLocationFromCache(ctx context.Context, cacheManager *cache.Cache[[
}

func makeBooking(ctx context.Context, auth *WeWorkAuthenticator, locationName string, date string, cacheManager *cache.Cache[[]byte]) error {
d, err := parseBookingDate(date)
if err != nil {
return err
}

bearerToken, weworkLocation, err := prepareBooking(ctx, auth, locationName, cacheManager)
if err != nil {
return err
}

return makeBookingRequestFunc(ctx, bearerToken, d, weworkLocation)
}

func parseBookingDate(date string) (time.Time, error) {
layout := "Jan 2, 2006"
// We do not need to check the error as this was already checked
d, _ := time.Parse(layout, date)

now := time.Now()

if d.Sub(now) > 31*24*time.Hour {
return ErrDateInOlderThanOneMonthFuture
return time.Time{}, ErrDateInOlderThanOneMonthFuture
}

return d, nil
}

func prepareBooking(ctx context.Context, auth *WeWorkAuthenticator, locationName string, cacheManager *cache.Cache[[]byte]) (string, WeWorkLocation, error) {
type tokenResult struct {
token string
err error
Expand All @@ -66,7 +84,7 @@ func makeBooking(ctx context.Context, auth *WeWorkAuthenticator, locationName st
locResult := <-locationCh
tokResult := <-tokenCh
if tokResult.err != nil {
return tokResult.err
return "", WeWorkLocation{}, tokResult.err
}
bearerToken := tokResult.token

Expand All @@ -78,7 +96,7 @@ func makeBooking(ctx context.Context, auth *WeWorkAuthenticator, locationName st
weworkLocation, err = FetchWeWorkLocationByName(ctx, bearerToken, locationName)

if err != nil {
return err
return "", WeWorkLocation{}, err
}

// Store in cache for 7 days
Expand All @@ -90,7 +108,7 @@ func makeBooking(ctx context.Context, auth *WeWorkAuthenticator, locationName st
}
}

return makeBookingRequest(ctx, bearerToken, d, weworkLocation)
return bearerToken, weworkLocation, nil
}

func weWorkLocationCacheKey(locationName string) string {
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func main() {

// also set up a custom logger
http.HandleFunc("/api/book", registerBookHandler(auth, cacheManager))
http.HandleFunc("/api/book/batch", registerBatchBookHandler(auth, cacheManager))
log.Println("Starting server on port 8080...")
log.Fatal(http.ListenAndServe(":8080", nil))

Expand Down
Loading
Loading