Update API Docs
This commit is contained in:
@@ -53,7 +53,7 @@ builder.Services.AddSwaggerGen(o =>
|
|||||||
{
|
{
|
||||||
Title = "PurrLobby API",
|
Title = "PurrLobby API",
|
||||||
Version = "v1",
|
Version = "v1",
|
||||||
Description = "PurrLobby is a lightweight lobby service. Many endpoints require a 'gameId' cookie to scope requests to your game. Obtain it by calling POST /session/game.",
|
Description = "PurrLobby is a secure lightweight lobby service with token-based authentication. All lobby endpoints require a valid session token and gameId cookie.",
|
||||||
Contact = new OpenApiContact { Name = "PurrLobby", Url = new Uri("https://purrlobby.exil.dev") }
|
Contact = new OpenApiContact { Name = "PurrLobby", Url = new Uri("https://purrlobby.exil.dev") }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,12 +65,12 @@ builder.Services.AddSwaggerGen(o =>
|
|||||||
Description = "Game scope cookie set by POST /session/game."
|
Description = "Game scope cookie set by POST /session/game."
|
||||||
});
|
});
|
||||||
|
|
||||||
o.AddSecurityDefinition("userIdCookie", new OpenApiSecurityScheme
|
o.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
|
||||||
{
|
{
|
||||||
Type = SecuritySchemeType.ApiKey,
|
Type = SecuritySchemeType.Http,
|
||||||
In = ParameterLocation.Cookie,
|
Scheme = "bearer",
|
||||||
Name = "userId",
|
BearerFormat = "JWT",
|
||||||
Description = "Player identity cookie generated by the server (POST /session/user)."
|
Description = "Secure session token obtained from POST /auth/create. Include in Authorization header as 'Bearer <token>' or as 'token' query parameter."
|
||||||
});
|
});
|
||||||
|
|
||||||
o.AddSecurityRequirement(new OpenApiSecurityRequirement
|
o.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||||
@@ -81,6 +81,13 @@ builder.Services.AddSwaggerGen(o =>
|
|||||||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "gameIdCookie" }
|
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "gameIdCookie" }
|
||||||
},
|
},
|
||||||
Array.Empty<string>()
|
Array.Empty<string>()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
|
||||||
|
},
|
||||||
|
Array.Empty<string>()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -109,6 +116,96 @@ app.UseStaticFiles();
|
|||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
app.MapRazorPages(); // Enable Razor Pages
|
app.MapRazorPages(); // Enable Razor Pages
|
||||||
|
|
||||||
|
// Authentication endpoints
|
||||||
|
app.MapPost("/auth/create", async (IAuthenticationService authService, CreateSessionRequest req, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.UserId) || string.IsNullOrWhiteSpace(req.DisplayName))
|
||||||
|
return Results.BadRequest(new { error = "UserId and DisplayName are required" });
|
||||||
|
|
||||||
|
var session = await authService.CreateSessionAsync(req.UserId, req.DisplayName, ct);
|
||||||
|
return Results.Ok(session);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ax)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = ax.Message });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Results.Problem("Internal server error", statusCode: 500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithTags("Authentication")
|
||||||
|
.WithOpenApi(op =>
|
||||||
|
{
|
||||||
|
op.Summary = "Create authentication session";
|
||||||
|
op.Description = "Creates a secure session token for a user. Returns a session token that must be used for all subsequent API calls.";
|
||||||
|
op.Security.Clear();
|
||||||
|
return op;
|
||||||
|
})
|
||||||
|
.Accepts<CreateSessionRequest>("application/json")
|
||||||
|
.Produces<UserSession>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
|
app.MapPost("/auth/validate", async (IAuthenticationService authService, ValidateTokenRequest req, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Token))
|
||||||
|
return Results.BadRequest(new { error = "Token is required" });
|
||||||
|
|
||||||
|
var result = await authService.ValidateTokenAsync(req.Token, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Results.Problem("Internal server error", statusCode: 500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithTags("Authentication")
|
||||||
|
.WithOpenApi(op =>
|
||||||
|
{
|
||||||
|
op.Summary = "Validate session token";
|
||||||
|
op.Description = "Validates a session token and returns user information if valid.";
|
||||||
|
op.Security.Clear();
|
||||||
|
return op;
|
||||||
|
})
|
||||||
|
.Accepts<ValidateTokenRequest>("application/json")
|
||||||
|
.Produces<TokenValidationResult>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
|
app.MapPost("/auth/revoke", async (IAuthenticationService authService, RevokeTokenRequest req, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Token))
|
||||||
|
return Results.BadRequest(new { error = "Token is required" });
|
||||||
|
|
||||||
|
var result = await authService.RevokeTokenAsync(req.Token, ct);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Results.Problem("Internal server error", statusCode: 500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.WithTags("Authentication")
|
||||||
|
.WithOpenApi(op =>
|
||||||
|
{
|
||||||
|
op.Summary = "Revoke session token";
|
||||||
|
op.Description = "Revokes a session token, making it invalid for future use.";
|
||||||
|
op.Security.Clear();
|
||||||
|
return op;
|
||||||
|
})
|
||||||
|
.Accepts<RevokeTokenRequest>("application/json")
|
||||||
|
.Produces<bool>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
static CookieOptions BuildStdCookieOptions() => new()
|
static CookieOptions BuildStdCookieOptions() => new()
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
@@ -200,7 +297,7 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Create a lobby";
|
op.Summary = "Create a lobby";
|
||||||
op.Description = "Creates a new lobby for the current game. Owner user id is server generated (cookie 'userId').";
|
op.Description = "Creates a new lobby for the current game. Requires valid session token for authentication.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Accepts<CreateLobbyRequest>("application/json")
|
.Accepts<CreateLobbyRequest>("application/json")
|
||||||
@@ -224,7 +321,7 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Join a lobby";
|
op.Summary = "Join a lobby";
|
||||||
op.Description = "Adds the current cookie user (server generated userId) to the specified lobby.";
|
op.Description = "Adds the authenticated user to the specified lobby. Session token is automatically extracted from request.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -249,7 +346,7 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Leave a lobby";
|
op.Summary = "Leave a lobby";
|
||||||
op.Description = "Removes the current cookie user from the lobby.";
|
op.Description = "Removes the authenticated user from the lobby.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Produces(StatusCodes.Status200OK)
|
.Produces(StatusCodes.Status200OK)
|
||||||
@@ -271,7 +368,7 @@ app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, in
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Search available lobbies";
|
op.Summary = "Search available lobbies";
|
||||||
op.Description = "Finds joinable lobbies for the current game. Excludes started or full lobbies.";
|
op.Description = "Finds joinable lobbies for the current game. Excludes started or full lobbies. Does not require authentication.";
|
||||||
op.Parameters.Add(new OpenApiParameter
|
op.Parameters.Add(new OpenApiParameter
|
||||||
{
|
{
|
||||||
Name = "maxRoomsToFind",
|
Name = "maxRoomsToFind",
|
||||||
@@ -303,7 +400,7 @@ app.MapGet("/lobbies/{lobbyId}", async (HttpContext http, ILobbyService service,
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Get lobby details";
|
op.Summary = "Get lobby details";
|
||||||
op.Description = "Returns the lobby object including ownership flag relative to current cookie user.";
|
op.Description = "Returns lobby object including ownership flag relative to the authenticated user.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Produces<Lobby>(StatusCodes.Status200OK)
|
.Produces<Lobby>(StatusCodes.Status200OK)
|
||||||
@@ -322,7 +419,7 @@ app.MapGet("/lobbies/{lobbyId}/members", async (HttpContext http, ILobbyService
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Get members of a lobby";
|
op.Summary = "Get members of a lobby";
|
||||||
op.Description = "Returns current members of the lobby in the current game, including readiness state.";
|
op.Description = "Returns current members of lobby in the current game, including readiness state. Requires authentication and membership in the lobby.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Produces<List<LobbyUser>>(StatusCodes.Status200OK)
|
.Produces<List<LobbyUser>>(StatusCodes.Status200OK)
|
||||||
@@ -368,7 +465,7 @@ app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService se
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Set a lobby data value";
|
op.Summary = "Set a lobby data value";
|
||||||
op.Description = "Sets or updates a single property on the lobby within the current game. Broadcasts a lobby_data event to subscribers.";
|
op.Description = "Sets or updates a single property on lobby within the current game. Only lobby owners can set data. Broadcasts a lobby_data event to subscribers.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Accepts<LobbyDataRequest>("application/json")
|
.Accepts<LobbyDataRequest>("application/json")
|
||||||
@@ -394,7 +491,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Set member ready state";
|
op.Summary = "Set member ready state";
|
||||||
op.Description = "Sets readiness for the specified user in the lobby. Request body must include userId and isReady. Broadcasts a member_ready event.";
|
op.Description = "Sets readiness for the authenticated user in the lobby. Request body must include isReady. Broadcasts a member_ready event.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Accepts<ReadyRequest>("application/json")
|
.Accepts<ReadyRequest>("application/json")
|
||||||
@@ -420,7 +517,7 @@ app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService s
|
|||||||
.WithOpenApi(op =>
|
.WithOpenApi(op =>
|
||||||
{
|
{
|
||||||
op.Summary = "Start lobby";
|
op.Summary = "Start lobby";
|
||||||
op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started.";
|
op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started. Only lobby owners can start lobbies.";
|
||||||
return op;
|
return op;
|
||||||
})
|
})
|
||||||
.Produces(StatusCodes.Status200OK)
|
.Produces(StatusCodes.Status200OK)
|
||||||
@@ -489,3 +586,8 @@ public record SetGameRequest(Guid GameId);
|
|||||||
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
|
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
|
||||||
public record ReadyRequest(bool IsReady);
|
public record ReadyRequest(bool IsReady);
|
||||||
public record LobbyDataRequest(string Key, string Value);
|
public record LobbyDataRequest(string Key, string Value);
|
||||||
|
|
||||||
|
// Authentication DTOs
|
||||||
|
public record CreateSessionRequest(string UserId, string DisplayName);
|
||||||
|
public record ValidateTokenRequest(string Token);
|
||||||
|
public record RevokeTokenRequest(string Token);
|
||||||
10
compose.yaml
10
compose.yaml
@@ -12,9 +12,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: final
|
target: final
|
||||||
ports:
|
labels:
|
||||||
- 443:443
|
- "traefik.enable=true"
|
||||||
- 8080:8080
|
- "traefik.http.routers.purrlobby.rule=Host(`purrlobby.exil.dev`)"
|
||||||
|
- "traefik.http.routers.purrlobby.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.purrlobby.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.purrlobby.loadbalancer.server.port=8080" # internal port your app listens on
|
||||||
|
|
||||||
|
|
||||||
# The commented out section below is an example of how to define a PostgreSQL
|
# The commented out section below is an example of how to define a PostgreSQL
|
||||||
# database that your application can use. `depends_on` tells Docker Compose to
|
# database that your application can use. `depends_on` tells Docker Compose to
|
||||||
|
|||||||
Reference in New Issue
Block a user