Skip to content

Commit 5d80716

Browse files
authored
feat(user): add get purchased games endpoint (#222)
* feat: add get purchased games endpoint * chore(docs): add docs for purchased game endpoint * chore(docs): update readme with getPurchasedGames endpoint * fix(test): fix getPurchasedGames tests * docs: use proper sort by type for getPurchasedGames
1 parent 10961ca commit 5d80716

15 files changed

Lines changed: 454 additions & 9 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ Click the function names to open their complete docs on the docs site.
129129
- [`getUserTrophyProfileSummary()`](https://psn-api.achievements.app/api-docs/user-trophies#getusertrophyprofilesummary) - Retrieve an overall summary of the number of trophies earned for a user broken down by type.
130130
- [`getUserTrophiesForSpecificTitle()`](https://psn-api.achievements.app/api-docs/user-trophies#getUserTrophiesForSpecificTitle) - Retrieve a summary of the trophies earned by a user for specific titles.
131131
- [`getRecentlyPlayedGames()`](https://psn-api.achievements.app/api-docs/users#getrecentlyplayedgames) - Retrieve a list of recently played games for the user associated with the access token provided to this function.
132+
- [`getPurchasedGames()`](https://psn-api.achievements.app/api-docs/users#getpurchasedgames) - Retrieve purchased games for the user associated with the access token. Returns only PS4 and PS5 games.
132133
- [`getUserPlayedGames()`](https://psn-api.achievements.app/api-docs/users#getuserplayedgames) - Retrieve a list of played games and playtime info (ordered by recency) associated with a user (either from token or external if privacy settings allow).
133134

134135
## Examples
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import nock from "nock";
2+
3+
import type { AuthorizationPayload, PurchasedGamesResponse } from "../models";
4+
import { getPurchasedGames } from "./getPurchasedGames";
5+
import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL";
6+
7+
const accessToken = "mockAccessToken";
8+
9+
describe("Function: getPurchasedGames", () => {
10+
afterEach(() => {
11+
nock.cleanAll();
12+
});
13+
14+
it("is defined #sanity", () => {
15+
// ASSERT
16+
expect(getPurchasedGames).toBeDefined();
17+
});
18+
19+
it("retrieves purchased games for the user", async () => {
20+
// ARRANGE
21+
const mockAuthorization: AuthorizationPayload = {
22+
accessToken
23+
};
24+
25+
const mockResponse: PurchasedGamesResponse = {
26+
data: {
27+
purchasedTitlesRetrieve: {
28+
__typename: "GameList",
29+
games: [
30+
{
31+
__typename: "GameLibraryTitle",
32+
conceptId: "203715",
33+
entitlementId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01",
34+
image: {
35+
__typename: "Media",
36+
url: "https://image.api.playstation.com/gs2-sec/appkgo/prod/CUSA01433_00/7/i_5c5e430a49994f22df5fd81f446ead7b6ae45027af490b415fe4e744a9918e4c/i/icon0.png"
37+
},
38+
isActive: true,
39+
isDownloadable: true,
40+
isPreOrder: false,
41+
membership: "NONE",
42+
name: "Rocket League®",
43+
platform: "PS4",
44+
productId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01",
45+
titleId: "CUSA01433_00"
46+
}
47+
]
48+
}
49+
}
50+
};
51+
52+
const baseUrlObj = new URL(GRAPHQL_BASE_URL);
53+
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
54+
const basePath = baseUrlObj.pathname;
55+
56+
// ... we need to use a nock matcher to verify the query parameters ...
57+
const expectedVariables = JSON.stringify({
58+
isActive: true,
59+
platform: ["ps4", "ps5"],
60+
size: 24,
61+
start: 0,
62+
sortBy: "ACTIVE_DATE",
63+
sortDirection: "desc"
64+
});
65+
const expectedExtensions = JSON.stringify({
66+
persistedQuery: {
67+
version: 1,
68+
sha256Hash:
69+
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168"
70+
}
71+
});
72+
73+
const mockScope = nock(baseUrl)
74+
.get(basePath)
75+
.query((params) => {
76+
expect(params.operationName).toEqual("getPurchasedGameList");
77+
expect(params.variables).toEqual(expectedVariables);
78+
expect(params.extensions).toEqual(expectedExtensions);
79+
return true;
80+
})
81+
.matchHeader("authorization", `Bearer ${accessToken}`)
82+
.reply(200, mockResponse);
83+
84+
// ACT
85+
const response = await getPurchasedGames(mockAuthorization);
86+
87+
// ASSERT
88+
expect(response).toEqual(mockResponse);
89+
expect(mockScope.isDone()).toBeTruthy();
90+
});
91+
92+
it("retrieves purchased games with custom options", async () => {
93+
// ARRANGE
94+
const mockAuthorization: AuthorizationPayload = {
95+
accessToken
96+
};
97+
98+
const mockResponse: PurchasedGamesResponse = {
99+
data: {
100+
purchasedTitlesRetrieve: {
101+
__typename: "GameList",
102+
games: []
103+
}
104+
}
105+
};
106+
107+
const baseUrlObj = new URL(GRAPHQL_BASE_URL);
108+
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
109+
const basePath = baseUrlObj.pathname;
110+
111+
const expectedVariables = JSON.stringify({
112+
isActive: false,
113+
platform: ["ps4", "ps5"],
114+
size: 50,
115+
start: 0,
116+
sortBy: "ACTIVE_DATE",
117+
sortDirection: "desc"
118+
});
119+
const expectedExtensions = JSON.stringify({
120+
persistedQuery: {
121+
version: 1,
122+
sha256Hash:
123+
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168"
124+
}
125+
});
126+
127+
const mockScope = nock(baseUrl)
128+
.get(basePath)
129+
.query((params) => {
130+
expect(params.operationName).toEqual("getPurchasedGameList");
131+
expect(params.variables).toEqual(expectedVariables);
132+
expect(params.extensions).toEqual(expectedExtensions);
133+
return true;
134+
})
135+
.matchHeader("authorization", `Bearer ${accessToken}`)
136+
.reply(200, mockResponse);
137+
138+
// ACT
139+
const response = await getPurchasedGames(mockAuthorization, {
140+
isActive: false,
141+
size: 50,
142+
sortBy: "ACTIVE_DATE"
143+
});
144+
145+
// ASSERT
146+
expect(response).toEqual(mockResponse);
147+
expect(mockScope.isDone()).toBeTruthy();
148+
});
149+
150+
it("throws an error if response data is null", async () => {
151+
// ARRANGE
152+
const mockAuthorization: AuthorizationPayload = {
153+
accessToken
154+
};
155+
156+
const mockErrorResponse = {
157+
data: null
158+
};
159+
160+
const baseUrlObj = new URL(GRAPHQL_BASE_URL);
161+
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
162+
const basePath = baseUrlObj.pathname;
163+
164+
nock(baseUrl)
165+
.get(basePath)
166+
.query(true)
167+
.matchHeader("authorization", `Bearer ${accessToken}`)
168+
.reply(200, mockErrorResponse);
169+
170+
// ASSERT
171+
await expect(getPurchasedGames(mockAuthorization)).rejects.toThrowError(
172+
JSON.stringify(mockErrorResponse)
173+
);
174+
});
175+
176+
it("throws an error if purchasedTitlesRetrieve is null", async () => {
177+
// ARRANGE
178+
const mockAuthorization: AuthorizationPayload = {
179+
accessToken
180+
};
181+
182+
const mockErrorResponse = {
183+
data: {
184+
purchasedTitlesRetrieve: null
185+
}
186+
};
187+
188+
const baseUrlObj = new URL(GRAPHQL_BASE_URL);
189+
const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`;
190+
const basePath = baseUrlObj.pathname;
191+
192+
nock(baseUrl)
193+
.get(basePath)
194+
.query(true)
195+
.matchHeader("authorization", `Bearer ${accessToken}`)
196+
.reply(200, mockErrorResponse);
197+
198+
// ASSERT
199+
await expect(getPurchasedGames(mockAuthorization)).rejects.toThrowError(
200+
JSON.stringify(mockErrorResponse)
201+
);
202+
});
203+
});

src/graphql/getPurchasedGames.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { AuthorizationPayload, PurchasedGamesResponse } from "../models";
2+
import { Membership } from "../models/membership.model";
3+
import { call } from "../utils/call";
4+
import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL";
5+
import { getPurchasedGameListHash } from "./operationHashes";
6+
7+
type GetPurchasedGamesOptions = {
8+
isActive: boolean;
9+
platform: ("ps4" | "ps5")[];
10+
size: number;
11+
start: number;
12+
sortBy: "ACTIVE_DATE";
13+
sortDirection: "asc" | "desc";
14+
membership: Membership;
15+
};
16+
17+
/**
18+
* A call to this function will retrieve purchased games for the user associated
19+
* with the npsso token provided to this module during initialisation.
20+
*
21+
* This endpoint returns only PS4 and PS5 games.
22+
*
23+
* @param authorization An object containing your access token, typically retrieved with `exchangeAccessCodeForAuthTokens()`.
24+
* @param options Optional parameters to filter and sort purchased games.
25+
*/
26+
export const getPurchasedGames = async (
27+
authorization: AuthorizationPayload,
28+
options: Partial<GetPurchasedGamesOptions> = {}
29+
): Promise<PurchasedGamesResponse> => {
30+
const url = new URL(GRAPHQL_BASE_URL);
31+
32+
const {
33+
isActive = true,
34+
platform = ["ps4", "ps5"],
35+
size = 24,
36+
start = 0,
37+
sortBy = "ACTIVE_DATE",
38+
sortDirection = "desc",
39+
...restOptions
40+
} = options;
41+
42+
url.searchParams.set("operationName", "getPurchasedGameList");
43+
url.searchParams.set(
44+
"variables",
45+
JSON.stringify({
46+
isActive,
47+
platform,
48+
size,
49+
start,
50+
sortBy,
51+
sortDirection,
52+
...restOptions
53+
})
54+
);
55+
url.searchParams.set(
56+
"extensions",
57+
JSON.stringify({
58+
persistedQuery: {
59+
version: 1,
60+
sha256Hash: getPurchasedGameListHash
61+
}
62+
})
63+
);
64+
65+
const response = await call<PurchasedGamesResponse>(
66+
{ url: url.toString() },
67+
authorization
68+
);
69+
70+
// The GraphQL queries can return non-truthy values.
71+
if (!response.data || !response.data.purchasedTitlesRetrieve) {
72+
throw new Error(JSON.stringify(response));
73+
}
74+
75+
return response;
76+
};

src/graphql/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from "./getPurchasedGames";
12
export * from "./getRecentlyPlayedGames";

src/graphql/operationHashes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@
1919
// "query getUserGameList($categories: String, $limit: Int, $orderBy: String, $subscriptionService: SubscriptionService) {\n gameLibraryTitlesRetrieve(categories: $categories, limit: $limit, orderBy: $orderBy, subscriptionService: $subscriptionService) {\n __typename\n games {\n __typename\n conceptId\n entitlementId\n image {\n __typename\n url\n }\n isActive\n lastPlayedDateTime\n name\n platform\n productId\n subscriptionService\n titleId\n }\n }\n}\n"
2020
export const getUserGameListHash =
2121
"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae";
22+
23+
export const getPurchasedGameListHash =
24+
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168";

src/models/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ export * from "./authorization-payload.model";
44
export * from "./basic-presence-response.model";
55
export * from "./call-valid-headers.model";
66
export * from "./get-user-friends-account-ids-response.model";
7+
export * from "./membership.model";
78
export * from "./profile-from-account-id-response.model";
89
export * from "./profile-from-user-name-response.model";
10+
export * from "./purchased-games-response.model";
911
export * from "./rarest-thin-trophy.model";
1012
export * from "./recently-played-games-response.model";
1113
export * from "./shareable-profile-link-response.model";

src/models/membership.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type Membership = "NONE" | "PS_PLUS";
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Membership } from "./membership.model";
2+
import { TitlePlatform } from "./title-platform.model";
3+
4+
export interface PurchasedGame {
5+
/** GraphQL object type/schema */
6+
__typename: "GameLibraryTitle";
7+
8+
/** Unique concept identifier for the game */
9+
conceptId: string | null;
10+
11+
/** Unique entitlement identifier */
12+
entitlementId: string;
13+
14+
/** Contains a url to a game icon file */
15+
image: {
16+
__typename: "Media";
17+
url: string;
18+
};
19+
20+
/** Whether the game is currently active */
21+
isActive: boolean;
22+
23+
/** Whether the game is downloadable */
24+
isDownloadable: boolean;
25+
26+
/** Whether the game is a pre-order */
27+
isPreOrder: boolean;
28+
29+
/** The membership level associated with this game */
30+
membership: Membership;
31+
32+
/** The name of the game */
33+
name: string;
34+
35+
/** The platform this game is available on */
36+
platform: TitlePlatform;
37+
38+
/** Unique product identifier */
39+
productId: string;
40+
41+
/** Unique title identifier */
42+
titleId: string;
43+
}
44+
45+
export interface PurchasedGamesResponse {
46+
data: {
47+
purchasedTitlesRetrieve: {
48+
__typename: "GameList";
49+
games: PurchasedGame[];
50+
};
51+
};
52+
}

src/models/user-devices-response.model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface AccountDevicesResponse {
1313
*/
1414
deviceType: string;
1515

16-
/**
16+
/**
1717
* The activation type.
1818
* @example "PRIMARY" | "PSN_GAME_V3"
1919
*/

0 commit comments

Comments
 (0)