feat: game lobby service
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
package publichttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/cancelgame"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/getgame"
|
||||
"galaxy/lobby/internal/service/listgames"
|
||||
"galaxy/lobby/internal/service/openenrollment"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
"galaxy/lobby/internal/service/updategame"
|
||||
)
|
||||
|
||||
// xUserIDHeader is the authenticated-user identifier header injected by
|
||||
// Edge Gateway on every public-port request.
|
||||
const xUserIDHeader = "X-User-ID"
|
||||
|
||||
// Public HTTP route patterns registered by registerGameRoutes.
|
||||
const (
|
||||
gamesCollectionPath = "/api/v1/lobby/games"
|
||||
gameItemPath = "/api/v1/lobby/games/{game_id}"
|
||||
openEnrollmentPath = "/api/v1/lobby/games/{game_id}/open-enrollment"
|
||||
cancelGamePath = "/api/v1/lobby/games/{game_id}/cancel"
|
||||
gameIDPathParamValue = "game_id"
|
||||
)
|
||||
|
||||
// errorResponse mirrors the `{ "error": { ... } }` shape documented in the
|
||||
// OpenAPI contract.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// createGameRequest is the JSON shape for POST /api/v1/lobby/games.
|
||||
type createGameRequest struct {
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
GameType string `json:"game_type"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// updateGameRequest is the JSON shape for PATCH /api/v1/lobby/games/{id}.
|
||||
// Each field is optional; pointer types distinguish "absent" from zero.
|
||||
type updateGameRequest struct {
|
||||
GameName *string `json:"game_name"`
|
||||
Description *string `json:"description"`
|
||||
MinPlayers *int `json:"min_players"`
|
||||
MaxPlayers *int `json:"max_players"`
|
||||
StartGapHours *int `json:"start_gap_hours"`
|
||||
StartGapPlayers *int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt *int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule *string `json:"turn_schedule"`
|
||||
TargetEngineVersion *string `json:"target_engine_version"`
|
||||
}
|
||||
|
||||
// gameRecordResponse is the JSON shape of GameRecord per the OpenAPI
|
||||
// contract. Timestamps follow the mixed convention frozen by the
|
||||
// `enrollment_ends_at` is Unix seconds; `created_at`, `updated_at`,
|
||||
// `started_at`, `finished_at`, `runtime_binding.bound_at` are Unix
|
||||
// milliseconds.
|
||||
type gameRecordResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
GameType string `json:"game_type"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
Status string `json:"status"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAt int64 `json:"enrollment_ends_at"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
StartedAt *int64 `json:"started_at,omitempty"`
|
||||
FinishedAt *int64 `json:"finished_at,omitempty"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineHealthSummary string `json:"engine_health_summary"`
|
||||
RuntimeBinding *runtimeBindingResponse `json:"runtime_binding,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeBindingResponse mirrors the RuntimeBinding schema. It is set
|
||||
// only after a successful container start.
|
||||
type runtimeBindingResponse struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
RuntimeJobID string `json:"runtime_job_id"`
|
||||
BoundAt int64 `json:"bound_at"`
|
||||
}
|
||||
|
||||
// encodeGameRecord converts one domain Game into the wire GameRecord shape.
|
||||
func encodeGameRecord(record game.Game) gameRecordResponse {
|
||||
resp := gameRecordResponse{
|
||||
GameID: record.GameID.String(),
|
||||
GameName: record.GameName,
|
||||
Description: record.Description,
|
||||
GameType: string(record.GameType),
|
||||
OwnerUserID: record.OwnerUserID,
|
||||
Status: string(record.Status),
|
||||
MinPlayers: record.MinPlayers,
|
||||
MaxPlayers: record.MaxPlayers,
|
||||
StartGapHours: record.StartGapHours,
|
||||
StartGapPlayers: record.StartGapPlayers,
|
||||
EnrollmentEndsAt: record.EnrollmentEndsAt.UTC().Unix(),
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
TargetEngineVersion: record.TargetEngineVersion,
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
|
||||
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
started := record.StartedAt.UTC().UnixMilli()
|
||||
resp.StartedAt = &started
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
finished := record.FinishedAt.UTC().UnixMilli()
|
||||
resp.FinishedAt = &finished
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
resp.RuntimeBinding = &runtimeBindingResponse{
|
||||
ContainerID: record.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
|
||||
BoundAt: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// decodeStrictJSON decodes body into target rejecting unknown fields and
|
||||
// any trailing content after the first JSON value.
|
||||
func decodeStrictJSON(body io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if decoder.More() {
|
||||
return errors.New("unexpected trailing content after JSON body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJSON marshals payload into the response body with the configured
|
||||
// status code.
|
||||
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
// writeError writes one OpenAPI-shaped error envelope.
|
||||
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
|
||||
writeJSON(writer, statusCode, errorResponse{Error: errorBody{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
// writeErrorFromService translates a service-layer error into the
|
||||
// OpenAPI-shaped error envelope using the stable error-code mapping.
|
||||
func writeErrorFromService(writer http.ResponseWriter, logger *slog.Logger, err error) {
|
||||
switch {
|
||||
case errors.Is(err, shared.ErrForbidden):
|
||||
writeError(writer, http.StatusForbidden, "forbidden", "access denied")
|
||||
case errors.Is(err, game.ErrNotFound),
|
||||
errors.Is(err, application.ErrNotFound),
|
||||
errors.Is(err, invite.ErrNotFound),
|
||||
errors.Is(err, membership.ErrNotFound),
|
||||
errors.Is(err, shared.ErrSubjectNotFound),
|
||||
errors.Is(err, ports.ErrPendingMissing):
|
||||
writeError(writer, http.StatusNotFound, "subject_not_found", "resource not found")
|
||||
case errors.Is(err, game.ErrConflict),
|
||||
errors.Is(err, game.ErrInvalidTransition),
|
||||
errors.Is(err, application.ErrConflict),
|
||||
errors.Is(err, application.ErrInvalidTransition),
|
||||
errors.Is(err, invite.ErrConflict),
|
||||
errors.Is(err, invite.ErrInvalidTransition),
|
||||
errors.Is(err, membership.ErrConflict),
|
||||
errors.Is(err, membership.ErrInvalidTransition):
|
||||
writeError(writer, http.StatusConflict, "conflict", "operation not allowed in current status")
|
||||
case errors.Is(err, shared.ErrEligibilityDenied):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "eligibility_denied", "user is not eligible to join games")
|
||||
case errors.Is(err, ports.ErrNameTaken):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "name_taken", "race name is already taken")
|
||||
case errors.Is(err, ports.ErrPendingExpired):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "race_name_pending_window_expired",
|
||||
"pending race-name registration window has expired")
|
||||
case errors.Is(err, ports.ErrQuotaExceeded):
|
||||
writeError(writer, http.StatusUnprocessableEntity, "race_name_registration_quota_exceeded",
|
||||
"race name registration quota exceeded")
|
||||
case errors.Is(err, shared.ErrServiceUnavailable),
|
||||
errors.Is(err, ports.ErrUserServiceUnavailable):
|
||||
writeError(writer, http.StatusServiceUnavailable, "service_unavailable", "service is unavailable")
|
||||
case isValidationError(err):
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
default:
|
||||
if logger != nil {
|
||||
logger.Error("unhandled service error", "err", err.Error())
|
||||
}
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidationError reports whether err is one of the domain-validation
|
||||
// errors returned from game.New, Game.Validate, or the ports UpdateStatus /
|
||||
// UpdateRuntimeSnapshot validators. These errors carry no sentinel and
|
||||
// surface as plain fmt.Errorf values, so we detect them structurally: the
|
||||
// cancel-game / update-game / open-enrollment services wrap them with the
|
||||
// service-level prefix so the transport layer only needs to know the
|
||||
// pre-sentinel error classes have already been consumed by earlier
|
||||
// switch arms.
|
||||
func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Conservative default: treat every remaining non-sentinel error that
|
||||
// carries a "must" / "must not" / "unsupported" substring as validation.
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "must "),
|
||||
strings.Contains(msg, "must not"),
|
||||
strings.Contains(msg, "is unsupported"),
|
||||
strings.Contains(msg, "invalid"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerGameRoutes binds the game-lifecycle and
|
||||
// game-read routes on mux.
|
||||
func registerGameRoutes(mux *http.ServeMux, deps Dependencies, logger *slog.Logger) {
|
||||
h := &gameHandlers{
|
||||
deps: deps,
|
||||
logger: logger.With("component", "public_http.games"),
|
||||
}
|
||||
mux.HandleFunc("POST "+gamesCollectionPath, h.handleCreate)
|
||||
mux.HandleFunc("GET "+gamesCollectionPath, h.handleList)
|
||||
mux.HandleFunc("GET "+gameItemPath, h.handleGet)
|
||||
mux.HandleFunc("PATCH "+gameItemPath, h.handleUpdate)
|
||||
mux.HandleFunc("POST "+openEnrollmentPath, h.handleOpenEnrollment)
|
||||
mux.HandleFunc("POST "+cancelGamePath, h.handleCancel)
|
||||
}
|
||||
|
||||
type gameHandlers struct {
|
||||
deps Dependencies
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// requireUserActor extracts the X-User-ID header and returns an Actor. It
|
||||
// writes the HTTP error envelope and returns false when the header is
|
||||
// missing or blank.
|
||||
func (h *gameHandlers) requireUserActor(writer http.ResponseWriter, request *http.Request) (shared.Actor, bool) {
|
||||
userID := strings.TrimSpace(request.Header.Get(xUserIDHeader))
|
||||
if userID == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request",
|
||||
"X-User-ID header is required")
|
||||
return shared.Actor{}, false
|
||||
}
|
||||
return shared.NewUserActor(userID), true
|
||||
}
|
||||
|
||||
// extractGameID reads the `game_id` path parameter; writes the
|
||||
// invalid_request envelope and returns false on failure. Value
|
||||
// structural validation is deferred to the domain layer.
|
||||
func (h *gameHandlers) extractGameID(writer http.ResponseWriter, request *http.Request) (common.GameID, bool) {
|
||||
raw := request.PathValue(gameIDPathParamValue)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return common.GameID(raw), true
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleCreate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CreateGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "create game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body createGameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := creategame.Input{
|
||||
Actor: actor,
|
||||
GameName: body.GameName,
|
||||
Description: body.Description,
|
||||
GameType: game.GameType(body.GameType),
|
||||
MinPlayers: body.MinPlayers,
|
||||
MaxPlayers: body.MaxPlayers,
|
||||
StartGapHours: body.StartGapHours,
|
||||
StartGapPlayers: body.StartGapPlayers,
|
||||
EnrollmentEndsAt: time.Unix(body.EnrollmentEndsAt, 0).UTC(),
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
}
|
||||
record, err := h.deps.CreateGame.Handle(request.Context(), input)
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusCreated, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleUpdate(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.UpdateGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "update game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body updateGameRequest
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := updategame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
GameName: body.GameName,
|
||||
Description: body.Description,
|
||||
MinPlayers: body.MinPlayers,
|
||||
MaxPlayers: body.MaxPlayers,
|
||||
StartGapHours: body.StartGapHours,
|
||||
StartGapPlayers: body.StartGapPlayers,
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
}
|
||||
if body.EnrollmentEndsAt != nil {
|
||||
t := time.Unix(*body.EnrollmentEndsAt, 0).UTC()
|
||||
input.EnrollmentEndsAt = &t
|
||||
}
|
||||
|
||||
record, err := h.deps.UpdateGame.Handle(request.Context(), input)
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleOpenEnrollment(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.OpenEnrollment == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "open enrollment service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.OpenEnrollment.Handle(request.Context(), openenrollment.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleCancel(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.CancelGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "cancel game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.CancelGame.Handle(request.Context(), cancelgame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
// gameListResponse mirrors the OpenAPI GameListResponse schema used by
|
||||
// GET /api/v1/lobby/games and the `lobby.my_games.list` route. Items
|
||||
// are always non-nil so the JSON form carries `[]` rather than `null`.
|
||||
type gameListResponse struct {
|
||||
Items []gameRecordResponse `json:"items"`
|
||||
NextPageToken string `json:"next_page_token,omitempty"`
|
||||
}
|
||||
|
||||
func encodeGameList(items []game.Game, nextPageToken string) gameListResponse {
|
||||
resp := gameListResponse{
|
||||
Items: make([]gameRecordResponse, 0, len(items)),
|
||||
NextPageToken: nextPageToken,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, encodeGameRecord(item))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// parsePage decodes the `page_size` and `page_token` query parameters
|
||||
// into a shared.Page. On failure it writes the OpenAPI-shaped
|
||||
// invalid_request envelope and returns ok=false so the caller can
|
||||
// short-circuit.
|
||||
func parsePage(writer http.ResponseWriter, request *http.Request) (shared.Page, bool) {
|
||||
page, err := shared.ParsePage(
|
||||
request.URL.Query().Get("page_size"),
|
||||
request.URL.Query().Get("page_token"),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return shared.Page{}, false
|
||||
}
|
||||
return page, true
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleGet(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.GetGame == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "get game service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gameID, ok := h.extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.deps.GetGame.Handle(request.Context(), getgame.Input{
|
||||
Actor: actor,
|
||||
GameID: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameRecord(record))
|
||||
}
|
||||
|
||||
func (h *gameHandlers) handleList(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.deps.ListGames == nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "list games service is not wired")
|
||||
return
|
||||
}
|
||||
actor, ok := h.requireUserActor(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := parsePage(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.deps.ListGames.Handle(request.Context(), listgames.Input{
|
||||
Actor: actor,
|
||||
Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
writeErrorFromService(writer, h.logger, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeGameList(out.Items, out.NextPageToken))
|
||||
}
|
||||
Reference in New Issue
Block a user