Update API Docs

This commit is contained in:
Exil Productions
2025-12-10 04:46:21 +01:00
parent 1ec84530dc
commit 6f870887d5
2 changed files with 125 additions and 19 deletions

View File

@@ -53,7 +53,7 @@ builder.Services.AddSwaggerGen(o =>
{
Title = "PurrLobby API",
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") }
});
@@ -65,12 +65,12 @@ builder.Services.AddSwaggerGen(o =>
Description = "Game scope cookie set by POST /session/game."
});
o.AddSecurityDefinition("userIdCookie", new OpenApiSecurityScheme
o.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Cookie,
Name = "userId",
Description = "Player identity cookie generated by the server (POST /session/user)."
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
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
@@ -81,6 +81,13 @@ builder.Services.AddSwaggerGen(o =>
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "gameIdCookie" }
},
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.UseSwaggerUI();
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()
{
HttpOnly = true,
@@ -200,7 +297,7 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo
.WithOpenApi(op =>
{
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;
})
.Accepts<CreateLobbyRequest>("application/json")
@@ -224,7 +321,7 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
.WithOpenApi(op =>
{
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;
})
@@ -249,7 +346,7 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s
.WithOpenApi(op =>
{
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;
})
.Produces(StatusCodes.Status200OK)
@@ -271,7 +368,7 @@ app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, in
.WithOpenApi(op =>
{
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
{
Name = "maxRoomsToFind",
@@ -303,7 +400,7 @@ app.MapGet("/lobbies/{lobbyId}", async (HttpContext http, ILobbyService service,
.WithOpenApi(op =>
{
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;
})
.Produces<Lobby>(StatusCodes.Status200OK)
@@ -322,7 +419,7 @@ app.MapGet("/lobbies/{lobbyId}/members", async (HttpContext http, ILobbyService
.WithOpenApi(op =>
{
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;
})
.Produces<List<LobbyUser>>(StatusCodes.Status200OK)
@@ -368,7 +465,7 @@ app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService se
.WithOpenApi(op =>
{
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;
})
.Accepts<LobbyDataRequest>("application/json")
@@ -394,7 +491,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
.WithOpenApi(op =>
{
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;
})
.Accepts<ReadyRequest>("application/json")
@@ -420,7 +517,7 @@ app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService s
.WithOpenApi(op =>
{
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;
})
.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 ReadyRequest(bool IsReady);
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);

View File

@@ -12,9 +12,13 @@ services:
build:
context: .
target: final
ports:
- 443:443
- 8080:8080
labels:
- "traefik.enable=true"
- "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
# database that your application can use. `depends_on` tells Docker Compose to