Skip to content

Commit 20fd56e

Browse files
Add POST /player/basic/batch (#147)
* Add POST /player/basic/batch for multi-player name resolution. Single round-trip for up to 12 membership IDs using WHERE membership_id = ANY(...). Used by Website fireteam display to avoid N+1 basic calls. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix basic-batch test typing and regenerate OpenAPI spec. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare membershipId as bigint in basic-batch test. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ba790a2 commit 20fd56e

5 files changed

Lines changed: 262 additions & 0 deletions

File tree

open-api/openapi.json

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2428,6 +2428,18 @@
24282428
"required": ["params", "results"],
24292429
"additionalProperties": false
24302430
},
2431+
"PlayerBasicBatchResponse": {
2432+
"type": "object",
2433+
"properties": {
2434+
"players": {
2435+
"type": "array",
2436+
"items": {
2437+
"$ref": "#/components/schemas/PlayerInfo"
2438+
}
2439+
}
2440+
},
2441+
"required": ["players"]
2442+
},
24312443
"PlayerHistoryResponse": {
24322444
"type": "object",
24332445
"properties": {
@@ -4169,6 +4181,144 @@
41694181
}
41704182
}
41714183
},
4184+
"/player/basic/batch": {
4185+
"post": {
4186+
"description": "Batch variant of `/player/{membershipId}/basic`. Resolves up to 12 players in one round-trip.",
4187+
"summary": "/player/basic/batch",
4188+
"requestBody": {
4189+
"required": true,
4190+
"content": {
4191+
"application/json": {
4192+
"schema": {
4193+
"type": "object",
4194+
"properties": {
4195+
"membershipIds": {
4196+
"type": "array",
4197+
"items": {
4198+
"type": "string",
4199+
"pattern": "^\\d+n?$"
4200+
},
4201+
"minItems": 1,
4202+
"maxItems": 12
4203+
}
4204+
},
4205+
"required": ["membershipIds"]
4206+
}
4207+
}
4208+
}
4209+
},
4210+
"responses": {
4211+
"200": {
4212+
"description": "Success",
4213+
"content": {
4214+
"application/json": {
4215+
"schema": {
4216+
"type": "object",
4217+
"properties": {
4218+
"minted": {
4219+
"type": "string",
4220+
"format": "date-time"
4221+
},
4222+
"success": {
4223+
"type": "boolean",
4224+
"enum": [true]
4225+
},
4226+
"response": {
4227+
"$ref": "#/components/schemas/PlayerBasicBatchResponse"
4228+
}
4229+
},
4230+
"required": ["minted", "success", "response"]
4231+
}
4232+
}
4233+
}
4234+
},
4235+
"400": {
4236+
"description": "Bad request",
4237+
"content": {
4238+
"application/json": {
4239+
"schema": {
4240+
"type": "object",
4241+
"properties": {
4242+
"minted": {
4243+
"type": "string",
4244+
"format": "date-time"
4245+
},
4246+
"success": {
4247+
"type": "boolean",
4248+
"enum": [false]
4249+
},
4250+
"code": {
4251+
"type": "string",
4252+
"enum": ["BodyValidationError"]
4253+
},
4254+
"error": {
4255+
"$ref": "#/components/schemas/BodyValidationError"
4256+
}
4257+
},
4258+
"required": ["minted", "success", "code", "error"]
4259+
}
4260+
}
4261+
}
4262+
},
4263+
"401": {
4264+
"description": "Unauthorized",
4265+
"content": {
4266+
"application/json": {
4267+
"schema": {
4268+
"type": "object",
4269+
"properties": {
4270+
"minted": {
4271+
"type": "string",
4272+
"format": "date-time"
4273+
},
4274+
"success": {
4275+
"type": "boolean",
4276+
"enum": [false]
4277+
},
4278+
"code": {
4279+
"type": "string",
4280+
"enum": ["ApiKeyError"]
4281+
},
4282+
"error": {
4283+
"$ref": "#/components/schemas/ApiKeyError"
4284+
}
4285+
},
4286+
"required": ["minted", "success", "code", "error"]
4287+
}
4288+
}
4289+
}
4290+
},
4291+
"500": {
4292+
"description": "Internal Server Error",
4293+
"content": {
4294+
"application/json": {
4295+
"schema": {
4296+
"type": "object",
4297+
"properties": {
4298+
"minted": {
4299+
"type": "string",
4300+
"format": "date-time"
4301+
},
4302+
"success": {
4303+
"type": "boolean",
4304+
"enum": [false]
4305+
},
4306+
"code": {
4307+
"type": "string",
4308+
"enum": ["InternalServerError"]
4309+
},
4310+
"error": {
4311+
"$ref": "#/components/schemas/InternalServerError"
4312+
}
4313+
},
4314+
"required": ["minted", "success", "code", "error"]
4315+
}
4316+
}
4317+
}
4318+
}
4319+
}
4320+
}
4321+
},
41724322
"/player/{membershipId}/history": {
41734323
"get": {
41744324
"description": "/activities is deprecated. Use /history now. Get a player's activity history. This endpoint uses date cursors to paginate through a player's activity history. \nThe first request should not include a cursor. Subsequent requests should include the `nextCursor` \nvalue from the previous response. Note that the first request may not return the full number of activities requested\nin order to optimize performance. Subsequent requests will return the full number of activities requested.",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
2+
3+
import { getFixturePool } from "@/lib/test-fixture-db"
4+
import { expectOk } from "@/lib/test-utils"
5+
6+
import { playerBasicBatchRoute } from "./basic-batch"
7+
8+
const fixtureDb = getFixturePool()
9+
const fixtureMembershipId = "4611686019000000402"
10+
11+
beforeAll(async () => {
12+
await fixtureDb.query(`DELETE FROM core.player_stats WHERE membership_id = $1::bigint`, [
13+
fixtureMembershipId
14+
])
15+
await fixtureDb.query(`DELETE FROM core.player WHERE membership_id = $1::bigint`, [
16+
fixtureMembershipId
17+
])
18+
await fixtureDb.query(
19+
`INSERT INTO core.player (
20+
membership_id, membership_type, icon_path, display_name,
21+
bungie_global_display_name, bungie_global_display_name_code, last_seen, first_seen,
22+
clears, fresh_clears, sherpas, total_time_played_seconds, sum_of_best, wfr_score,
23+
cheat_level, is_private, is_whitelisted, updated_at
24+
) VALUES
25+
($1::bigint, 3, NULL, 'fixture_basic_batch', 'fixture_basic_batch', '0402', NOW(), NOW(), 1, 1, 0, 100, 100, 0, 0, false, false, NOW())`,
26+
[fixtureMembershipId]
27+
)
28+
})
29+
30+
afterAll(async () => {
31+
await fixtureDb.query(`DELETE FROM core.player_stats WHERE membership_id = $1::bigint`, [
32+
fixtureMembershipId
33+
])
34+
await fixtureDb.query(`DELETE FROM core.player WHERE membership_id = $1::bigint`, [
35+
fixtureMembershipId
36+
])
37+
})
38+
39+
describe("player basic batch 200", () => {
40+
test("returns found players and omits unknown ids", async () => {
41+
const result = await playerBasicBatchRoute.$mock({
42+
body: {
43+
membershipIds: [fixtureMembershipId, "1"]
44+
}
45+
})
46+
47+
expectOk(result)
48+
if (result.type === "ok") {
49+
expect(result.parsed.players).toHaveLength(1)
50+
expect(result.parsed.players[0]?.membershipId).toBe(BigInt(fixtureMembershipId))
51+
}
52+
})
53+
})

src/routes/player/basic-batch.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { RaidHubRoute } from "@/core/RaidHubRoute"
2+
import { playersQueue } from "@/integrations/rabbitmq/queues"
3+
import { cacheControl } from "@/middleware/cache-control"
4+
import { zPlayerInfo } from "@/schema/components/PlayerInfo"
5+
import { zBigIntString } from "@/schema/input"
6+
import { getPlayers } from "@/services/player"
7+
import { z } from "zod"
8+
9+
export const playerBasicBatchRoute = new RaidHubRoute({
10+
method: "post",
11+
description:
12+
"Batch variant of `/player/{membershipId}/basic`. Resolves up to 12 players in one round-trip.",
13+
body: z.object({
14+
membershipIds: z.array(zBigIntString()).min(1).max(12)
15+
}),
16+
middleware: [cacheControl(300)],
17+
response: {
18+
success: {
19+
statusCode: 200,
20+
schema: z.object({
21+
players: z.array(zPlayerInfo)
22+
})
23+
},
24+
errors: []
25+
},
26+
async handler(req, after) {
27+
const players = await getPlayers(req.body.membershipIds)
28+
29+
after(async () => {
30+
await Promise.all(req.body.membershipIds.map(id => playersQueue.send(id)))
31+
})
32+
33+
return RaidHubRoute.ok({ players })
34+
}
35+
})

src/routes/player/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RaidHubRouter } from "@/core/RaidHubRouter"
2+
import { playerBasicBatchRoute } from "./basic-batch"
23
import { playerBasicRoute } from "./membershipId/basic"
34
import { playerHistoryRoute } from "./membershipId/history"
45
import { playerInstancesRoute } from "./membershipId/instances"
@@ -9,6 +10,7 @@ import { playerSearchRoute } from "./search"
910
export const playerRouter = new RaidHubRouter({
1011
routes: [
1112
{ path: "/search", route: playerSearchRoute },
13+
{ path: "/basic/batch", route: playerBasicBatchRoute },
1214
{
1315
path: "/:membershipId",
1416
route: new RaidHubRouter({

src/services/player.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ export const getPlayer = async (membershipId: bigint | string) => {
2626
{ params: [membershipId] }
2727
)
2828
}
29+
30+
export const getPlayers = async (membershipIds: readonly (bigint | string)[]) => {
31+
if (!membershipIds.length) {
32+
return []
33+
}
34+
35+
return await pgReader.queryRows<PlayerInfo>(
36+
`SELECT
37+
membership_id AS "membershipId",
38+
membership_type AS "membershipType",
39+
icon_path AS "iconPath",
40+
display_name AS "displayName",
41+
bungie_global_display_name AS "bungieGlobalDisplayName",
42+
bungie_global_display_name_code AS "bungieGlobalDisplayNameCode",
43+
last_seen AS "lastSeen",
44+
is_private AS "isPrivate",
45+
cheat_level AS "cheatLevel"
46+
FROM player
47+
WHERE membership_id = ANY($1::bigint[])`,
48+
{ params: [membershipIds] }
49+
)
50+
}
2951
export const getPlayerActivityStats = async (membershipId: bigint | string) => {
3052
return await withHistogramTimer(
3153
playerProfileQueryTimer,

0 commit comments

Comments
 (0)