From 0a642103ff4e1be1bb706fec2e7e6317bc0da518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Wirsztel?= Date: Fri, 1 May 2026 01:31:13 +0200 Subject: [PATCH] Added a batch endpoint to book multiple days --- README.md | 10 +++ api.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++ api_test.go | 120 ++++++++++++++++++++++++++++++++- browser.go | 26 ++++++-- main.go | 1 + weworkrequests.go | 2 + 6 files changed, 318 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2eab4df..49aedff 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api.go b/api.go index 18bd30c..186c526 100644 --- a/api.go +++ b/api.go @@ -2,10 +2,12 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log" "net/http" + "sync" "time" "github.com/eko/gocache/lib/v4/cache" @@ -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" diff --git a/api_test.go b/api_test.go index 6cbebed..42ee2a1 100644 --- a/api_test.go +++ b/api_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "context" + "testing" + "time" +) func TestReformatDate(t *testing.T) { tests := []struct { @@ -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) + } + } +} diff --git a/browser.go b/browser.go index fd379c8..9df73a5 100644 --- a/browser.go +++ b/browser.go @@ -31,6 +31,20 @@ 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) @@ -38,9 +52,13 @@ func makeBooking(ctx context.Context, auth *WeWorkAuthenticator, locationName st 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 @@ -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 @@ -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 @@ -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 { diff --git a/main.go b/main.go index 3222980..8e06d42 100644 --- a/main.go +++ b/main.go @@ -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)) diff --git a/weworkrequests.go b/weworkrequests.go index 397f19a..98e45bb 100644 --- a/weworkrequests.go +++ b/weworkrequests.go @@ -340,6 +340,8 @@ type BookingResponse struct { WeworkUUID string `json:"WeWorkUUID"` } +var makeBookingRequestFunc = makeBookingRequest + // parseTimezoneOffset parses a timezone offset string like "GMT +02:00" or "GMT -05:00" // and returns the offset in hours as a float64 func parseTimezoneOffset(tzOffset string) (float64, error) {