test/mockserver/main.go
442 linesgo
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"net/http"
	"strings"
	"sync"
	"time"
)

type User struct {
	ID         string     `json:"id"`
	Email      string     `json:"email"`
	FirstName  string     `json:"first_name"`
	LastName   string     `json:"last_name"`
	Username   string     `json:"username"`
	Status     string     `json:"status"`
	Department string     `json:"department,omitempty"`
	CreatedAt  time.Time  `json:"created_at"`
	LastLogin  *time.Time `json:"last_login,omitempty"`
}

type Role struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	Description string `json:"description"`
}

type Group struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	Description string `json:"description"`
}

type Member struct {
	UserID string `json:"user_id"`
}

type CreateUserRequest struct {
	Email     string `json:"email"`
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	Username  string `json:"username"`
	Password  string `json:"password,omitempty"`
}

type ListResponse[T any] struct {
	Data       []T    `json:"data"`
	NextCursor string `json:"next_cursor"`
}

type SingleResponse[T any] struct {
	Data T `json:"data"`
}

type TokenResponse struct {
	AccessToken string `json:"access_token"`
	ExpiresIn   int    `json:"expires_in"`
	TokenType   string `json:"token_type"`
}

type GroupAssignment struct {
	GroupID string `json:"group_id"`
}

type RoleAssignment struct {
	RoleID string `json:"role_id"`
}

type store struct {
	mu           sync.RWMutex
	users        []User
	roles        []Role
	groups       []Group
	roleMembers  map[string][]Member
	groupMembers map[string][]Member
	roleGroups   map[string][]GroupAssignment
	roleRoles    map[string][]RoleAssignment
	nextUserSeq  int
}

func newStore() *store {
	now := time.Now()
	return &store{
		users: []User{
			{ID: "u1", Email: "alice@appville.com", FirstName: "Alice", LastName: "Smith", Username: "alice", Status: "active", CreatedAt: now},
			{ID: "u2", Email: "bob@appville.com", FirstName: "Bob", LastName: "Jones", Username: "bob", Status: "active", CreatedAt: now},
			{ID: "u3", Email: "carol@appville.com", FirstName: "Carol", LastName: "White", Username: "carol", Status: "active", CreatedAt: now},
			{ID: "u4", Email: "dave@appville.com", FirstName: "Dave", LastName: "Brown", Username: "dave", Status: "active", CreatedAt: now},
			{ID: "u5", Email: "eve@appville.com", FirstName: "Eve", LastName: "Davis", Username: "eve", Status: "active", CreatedAt: now},
			{ID: "u6", Email: "frank@appville.com", FirstName: "Frank", LastName: "Miller", Username: "frank", Status: "active", CreatedAt: now},
		},
		roles: []Role{
			{ID: "r1", Name: "Single Ride", Description: "One-time journey on any standard route"},
			{ID: "r2", Name: "Standard Day", Description: "Unlimited standard travel for the day"},
			{ID: "r3", Name: "Dining Access", Description: "Access to the dining car service"},
			{ID: "r4", Name: "First Class", Description: "Premium seating with complimentary dining"},
			{ID: "r5", Name: "Express", Description: "Access to express routes with priority boarding"},
		},
		groups: []Group{
			{ID: "g1", Name: "Regional Pass", Description: "Monthly pass for standard daily travel"},
			{ID: "g2", Name: "Express Pass", Description: "Monthly pass for express routes"},
			{ID: "g3", Name: "All-Access Pass", Description: "Premium monthly pass with first-class travel and dining"},
		},
		roleMembers: map[string][]Member{
			"r1": {{UserID: "u6"}},
			"r2": {{UserID: "u4"}},
			"r3": {{UserID: "u2"}, {UserID: "u3"}, {UserID: "u4"}},
			"r4": {{UserID: "u5"}},
		},
		groupMembers: map[string][]Member{
			"g1": {{UserID: "u2"}},
			"g2": {{UserID: "u3"}},
			"g3": {{UserID: "u1"}},
		},
		roleGroups: map[string][]GroupAssignment{
			"r2": {{GroupID: "g1"}},
			"r4": {{GroupID: "g3"}},
			"r5": {{GroupID: "g2"}},
		},
		roleRoles: map[string][]RoleAssignment{
			"r3": {{RoleID: "r4"}},
		},
		nextUserSeq: 7,
	}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		log.Printf("ERROR writing JSON response: %v", err)
	}
}

func logRequest(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		log.Printf("%s %s", r.Method, r.URL)
		next(w, r)
	}
}

func main() {
	port := flag.Int("port", 8765, "port to listen on")
	flag.Parse()

	s := newStore()
	mux := http.NewServeMux()

	mux.HandleFunc("/oauth/token", logRequest(func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, TokenResponse{
			AccessToken: "mock-server-token",
			ExpiresIn:   3600,
			TokenType:   "Bearer",
		})
	}))

	mux.HandleFunc("/api/users/me", logRequest(func(w http.ResponseWriter, r *http.Request) {
		s.mu.RLock()
		defer s.mu.RUnlock()
		writeJSON(w, http.StatusOK, SingleResponse[User]{Data: s.users[0]})
	}))

	mux.HandleFunc("/api/users", logRequest(func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodPost:
			var req CreateUserRequest
			if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
				http.Error(w, "bad request", http.StatusBadRequest)
				return
			}

			s.mu.Lock()
			id := fmt.Sprintf("u%d", s.nextUserSeq)
			s.nextUserSeq++
			u := User{
				ID:        id,
				Email:     req.Email,
				FirstName: req.FirstName,
				LastName:  req.LastName,
				Username:  req.Username,
				Status:    "active",
				CreatedAt: time.Now(),
			}
			s.users = append(s.users, u)
			s.mu.Unlock()

			log.Printf("  -> created user %s (%s)", id, req.Email)
			writeJSON(w, http.StatusCreated, SingleResponse[User]{Data: u})

		case http.MethodGet:
			s.mu.RLock()
			defer s.mu.RUnlock()

			const pageSize = 2
			cursor := r.URL.Query().Get("cursor")

			start := 0
			if cursor != "" {
				for i, u := range s.users {
					if u.ID == cursor {
						start = i
						break
					}
				}
			}

			end := start + pageSize
			if end > len(s.users) {
				end = len(s.users)
			}

			next := ""
			if end < len(s.users) {
				next = s.users[end].ID
			}

			writeJSON(w, http.StatusOK, ListResponse[User]{
				Data:       s.users[start:end],
				NextCursor: next,
			})

		default:
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		}
	}))

	mux.HandleFunc("/api/users/", logRequest(func(w http.ResponseWriter, r *http.Request) {
		id := strings.TrimPrefix(r.URL.Path, "/api/users/")
		if id == "" || id == "me" {
			return
		}

		s.mu.Lock()
		defer s.mu.Unlock()

		idx := -1
		for i, u := range s.users {
			if u.ID == id {
				idx = i
				break
			}
		}
		if idx == -1 {
			http.NotFound(w, r)
			return
		}

		switch r.Method {
		case http.MethodGet:
			writeJSON(w, http.StatusOK, SingleResponse[User]{Data: s.users[idx]})

		case http.MethodPatch:
			var attrs map[string]string
			if err := json.NewDecoder(r.Body).Decode(&attrs); err != nil {
				http.Error(w, "bad request", http.StatusBadRequest)
				return
			}
			if v, ok := attrs["status"]; ok {
				s.users[idx].Status = v
			}
			if v, ok := attrs["first_name"]; ok {
				s.users[idx].FirstName = v
			}
			if v, ok := attrs["last_name"]; ok {
				s.users[idx].LastName = v
			}
			if v, ok := attrs["email"]; ok {
				s.users[idx].Email = v
			}
			if v, ok := attrs["department"]; ok {
				s.users[idx].Department = v
			}
			log.Printf("  -> updated user %s", id)
			writeJSON(w, http.StatusOK, SingleResponse[User]{Data: s.users[idx]})

		default:
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		}
	}))

	mux.HandleFunc("/api/roles", logRequest(func(w http.ResponseWriter, r *http.Request) {
		s.mu.RLock()
		defer s.mu.RUnlock()
		writeJSON(w, http.StatusOK, ListResponse[Role]{Data: s.roles})
	}))

	mux.HandleFunc("/api/roles/", logRequest(func(w http.ResponseWriter, r *http.Request) {
		parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/roles/"), "/")
		roleID := parts[0]

		if len(parts) >= 2 && parts[1] == "members" {
			if len(parts) == 3 {
				userID := parts[2]
				s.mu.Lock()
				defer s.mu.Unlock()

				switch r.Method {
				case http.MethodPut:
					members := s.roleMembers[roleID]
					for _, m := range members {
						if m.UserID == userID {
							w.WriteHeader(http.StatusNoContent)
							return
						}
					}
					s.roleMembers[roleID] = append(s.roleMembers[roleID], Member{UserID: userID})
					log.Printf("  -> added %s to role %s", userID, roleID)
					w.WriteHeader(http.StatusNoContent)

				case http.MethodDelete:
					members := s.roleMembers[roleID]
					for i, m := range members {
						if m.UserID == userID {
							s.roleMembers[roleID] = append(members[:i], members[i+1:]...)
							break
						}
					}
					log.Printf("  -> removed %s from role %s", userID, roleID)
					w.WriteHeader(http.StatusNoContent)

				default:
					http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
				}
				return
			}

			s.mu.RLock()
			defer s.mu.RUnlock()
			writeJSON(w, http.StatusOK, ListResponse[Member]{Data: s.roleMembers[roleID]})
			return
		}

		if len(parts) == 2 && parts[1] == "groups" {
			s.mu.RLock()
			defer s.mu.RUnlock()
			writeJSON(w, http.StatusOK, ListResponse[GroupAssignment]{Data: s.roleGroups[roleID]})
			return
		}

		if len(parts) == 2 && parts[1] == "roles" {
			s.mu.RLock()
			defer s.mu.RUnlock()
			writeJSON(w, http.StatusOK, ListResponse[RoleAssignment]{Data: s.roleRoles[roleID]})
			return
		}

		if len(parts) == 1 {
			s.mu.RLock()
			defer s.mu.RUnlock()
			for _, rl := range s.roles {
				if rl.ID == roleID {
					writeJSON(w, http.StatusOK, SingleResponse[Role]{Data: rl})
					return
				}
			}
		}

		http.NotFound(w, r)
	}))

	mux.HandleFunc("/api/groups", logRequest(func(w http.ResponseWriter, r *http.Request) {
		s.mu.RLock()
		defer s.mu.RUnlock()
		writeJSON(w, http.StatusOK, ListResponse[Group]{Data: s.groups})
	}))

	mux.HandleFunc("/api/groups/", logRequest(func(w http.ResponseWriter, r *http.Request) {
		parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/groups/"), "/")
		groupID := parts[0]

		if len(parts) >= 2 && parts[1] == "members" {
			if len(parts) == 3 {
				userID := parts[2]
				s.mu.Lock()
				defer s.mu.Unlock()

				switch r.Method {
				case http.MethodPut:
					members := s.groupMembers[groupID]
					for _, m := range members {
						if m.UserID == userID {
							w.WriteHeader(http.StatusNoContent)
							return
						}
					}
					s.groupMembers[groupID] = append(s.groupMembers[groupID], Member{UserID: userID})
					log.Printf("  -> added %s to group %s", userID, groupID)
					w.WriteHeader(http.StatusNoContent)

				case http.MethodDelete:
					members := s.groupMembers[groupID]
					for i, m := range members {
						if m.UserID == userID {
							s.groupMembers[groupID] = append(members[:i], members[i+1:]...)
							break
						}
					}
					log.Printf("  -> removed %s from group %s", userID, groupID)
					w.WriteHeader(http.StatusNoContent)

				default:
					http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
				}
				return
			}

			s.mu.RLock()
			defer s.mu.RUnlock()
			writeJSON(w, http.StatusOK, ListResponse[Member]{Data: s.groupMembers[groupID]})
			return
		}

		if len(parts) == 1 {
			s.mu.RLock()
			defer s.mu.RUnlock()
			for _, g := range s.groups {
				if g.ID == groupID {
					writeJSON(w, http.StatusOK, SingleResponse[Group]{Data: g})
					return
				}
			}
		}

		http.NotFound(w, r)
	}))

	addr := fmt.Sprintf(":%d", *port)
	log.Printf("Mock API server starting on http://localhost%s", addr)
	log.Printf("OAuth token endpoint: POST /oauth/token")
	log.Printf("Users:  GET /api/users, POST /api/users, GET/PATCH /api/users/{id}")
	log.Printf("Roles:  GET /api/roles, GET /api/roles/{id}, GET /api/roles/{id}/members")
	log.Printf("        PUT /api/roles/{id}/members/{uid}, DELETE /api/roles/{id}/members/{uid}")
	log.Printf("        GET /api/roles/{id}/groups, GET /api/roles/{id}/roles")
	log.Printf("Groups: GET /api/groups, GET /api/groups/{id}, GET /api/groups/{id}/members")
	log.Printf("        PUT /api/groups/{id}/members/{uid}, DELETE /api/groups/{id}/members/{uid}")
	log.Fatal(http.ListenAndServe(addr, mux))
}