diff --git a/README.md b/README.md index a68f68f..d671a78 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Click the function names to open their complete docs on the docs site. - [`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. - [`getUserTrophiesForSpecificTitle()`](https://psn-api.achievements.app/api-docs/user-trophies#getUserTrophiesForSpecificTitle) - Retrieve a summary of the trophies earned by a user for specific titles. - [`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. +- [`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. - [`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). ## Examples diff --git a/src/graphql/getPurchasedGames.test.ts b/src/graphql/getPurchasedGames.test.ts new file mode 100644 index 0000000..82a094e --- /dev/null +++ b/src/graphql/getPurchasedGames.test.ts @@ -0,0 +1,203 @@ +import nock from "nock"; + +import type { AuthorizationPayload, PurchasedGamesResponse } from "../models"; +import { getPurchasedGames } from "./getPurchasedGames"; +import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL"; + +const accessToken = "mockAccessToken"; + +describe("Function: getPurchasedGames", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("is defined #sanity", () => { + // ASSERT + expect(getPurchasedGames).toBeDefined(); + }); + + it("retrieves purchased games for the user", async () => { + // ARRANGE + const mockAuthorization: AuthorizationPayload = { + accessToken + }; + + const mockResponse: PurchasedGamesResponse = { + data: { + purchasedTitlesRetrieve: { + __typename: "GameList", + games: [ + { + __typename: "GameLibraryTitle", + conceptId: "203715", + entitlementId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01", + image: { + __typename: "Media", + url: "https://image.api.playstation.com/gs2-sec/appkgo/prod/CUSA01433_00/7/i_5c5e430a49994f22df5fd81f446ead7b6ae45027af490b415fe4e744a9918e4c/i/icon0.png" + }, + isActive: true, + isDownloadable: true, + isPreOrder: false, + membership: "NONE", + name: "Rocket League®", + platform: "PS4", + productId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01", + titleId: "CUSA01433_00" + } + ] + } + } + }; + + const baseUrlObj = new URL(GRAPHQL_BASE_URL); + const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + const basePath = baseUrlObj.pathname; + + // ... we need to use a nock matcher to verify the query parameters ... + const expectedVariables = JSON.stringify({ + isActive: true, + platform: ["ps4", "ps5"], + size: 24, + start: 0, + sortBy: "ACTIVE_DATE", + sortDirection: "desc" + }); + const expectedExtensions = JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: + "827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168" + } + }); + + const mockScope = nock(baseUrl) + .get(basePath) + .query((params) => { + expect(params.operationName).toEqual("getPurchasedGameList"); + expect(params.variables).toEqual(expectedVariables); + expect(params.extensions).toEqual(expectedExtensions); + return true; + }) + .matchHeader("authorization", `Bearer ${accessToken}`) + .reply(200, mockResponse); + + // ACT + const response = await getPurchasedGames(mockAuthorization); + + // ASSERT + expect(response).toEqual(mockResponse); + expect(mockScope.isDone()).toBeTruthy(); + }); + + it("retrieves purchased games with custom options", async () => { + // ARRANGE + const mockAuthorization: AuthorizationPayload = { + accessToken + }; + + const mockResponse: PurchasedGamesResponse = { + data: { + purchasedTitlesRetrieve: { + __typename: "GameList", + games: [] + } + } + }; + + const baseUrlObj = new URL(GRAPHQL_BASE_URL); + const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + const basePath = baseUrlObj.pathname; + + const expectedVariables = JSON.stringify({ + isActive: false, + platform: ["ps4", "ps5"], + size: 50, + start: 0, + sortBy: "ACTIVE_DATE", + sortDirection: "desc" + }); + const expectedExtensions = JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: + "827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168" + } + }); + + const mockScope = nock(baseUrl) + .get(basePath) + .query((params) => { + expect(params.operationName).toEqual("getPurchasedGameList"); + expect(params.variables).toEqual(expectedVariables); + expect(params.extensions).toEqual(expectedExtensions); + return true; + }) + .matchHeader("authorization", `Bearer ${accessToken}`) + .reply(200, mockResponse); + + // ACT + const response = await getPurchasedGames(mockAuthorization, { + isActive: false, + size: 50, + sortBy: "ACTIVE_DATE" + }); + + // ASSERT + expect(response).toEqual(mockResponse); + expect(mockScope.isDone()).toBeTruthy(); + }); + + it("throws an error if response data is null", async () => { + // ARRANGE + const mockAuthorization: AuthorizationPayload = { + accessToken + }; + + const mockErrorResponse = { + data: null + }; + + const baseUrlObj = new URL(GRAPHQL_BASE_URL); + const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + const basePath = baseUrlObj.pathname; + + nock(baseUrl) + .get(basePath) + .query(true) + .matchHeader("authorization", `Bearer ${accessToken}`) + .reply(200, mockErrorResponse); + + // ASSERT + await expect(getPurchasedGames(mockAuthorization)).rejects.toThrowError( + JSON.stringify(mockErrorResponse) + ); + }); + + it("throws an error if purchasedTitlesRetrieve is null", async () => { + // ARRANGE + const mockAuthorization: AuthorizationPayload = { + accessToken + }; + + const mockErrorResponse = { + data: { + purchasedTitlesRetrieve: null + } + }; + + const baseUrlObj = new URL(GRAPHQL_BASE_URL); + const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + const basePath = baseUrlObj.pathname; + + nock(baseUrl) + .get(basePath) + .query(true) + .matchHeader("authorization", `Bearer ${accessToken}`) + .reply(200, mockErrorResponse); + + // ASSERT + await expect(getPurchasedGames(mockAuthorization)).rejects.toThrowError( + JSON.stringify(mockErrorResponse) + ); + }); +}); diff --git a/src/graphql/getPurchasedGames.ts b/src/graphql/getPurchasedGames.ts new file mode 100644 index 0000000..0759097 --- /dev/null +++ b/src/graphql/getPurchasedGames.ts @@ -0,0 +1,76 @@ +import type { AuthorizationPayload, PurchasedGamesResponse } from "../models"; +import { Membership } from "../models/membership.model"; +import { call } from "../utils/call"; +import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL"; +import { getPurchasedGameListHash } from "./operationHashes"; + +type GetPurchasedGamesOptions = { + isActive: boolean; + platform: ("ps4" | "ps5")[]; + size: number; + start: number; + sortBy: "ACTIVE_DATE"; + sortDirection: "asc" | "desc"; + membership: Membership; +}; + +/** + * A call to this function will retrieve purchased games for the user associated + * with the npsso token provided to this module during initialisation. + * + * This endpoint returns only PS4 and PS5 games. + * + * @param authorization An object containing your access token, typically retrieved with `exchangeAccessCodeForAuthTokens()`. + * @param options Optional parameters to filter and sort purchased games. + */ +export const getPurchasedGames = async ( + authorization: AuthorizationPayload, + options: Partial = {} +): Promise => { + const url = new URL(GRAPHQL_BASE_URL); + + const { + isActive = true, + platform = ["ps4", "ps5"], + size = 24, + start = 0, + sortBy = "ACTIVE_DATE", + sortDirection = "desc", + ...restOptions + } = options; + + url.searchParams.set("operationName", "getPurchasedGameList"); + url.searchParams.set( + "variables", + JSON.stringify({ + isActive, + platform, + size, + start, + sortBy, + sortDirection, + ...restOptions + }) + ); + url.searchParams.set( + "extensions", + JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: getPurchasedGameListHash + } + }) + ); + + const response = await call( + { url: url.toString() }, + authorization + ); + + // The GraphQL queries can return non-truthy values. + if (!response.data || !response.data.purchasedTitlesRetrieve) { + throw new Error(JSON.stringify(response)); + } + + return response; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index da84229..a7da725 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -1 +1,2 @@ +export * from "./getPurchasedGames"; export * from "./getRecentlyPlayedGames"; diff --git a/src/graphql/operationHashes.ts b/src/graphql/operationHashes.ts index eade307..5567e2f 100644 --- a/src/graphql/operationHashes.ts +++ b/src/graphql/operationHashes.ts @@ -19,3 +19,6 @@ // "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" export const getUserGameListHash = "e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae"; + +export const getPurchasedGameListHash = + "827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168"; diff --git a/src/models/index.ts b/src/models/index.ts index 1e10359..92a8719 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -4,8 +4,10 @@ export * from "./authorization-payload.model"; export * from "./basic-presence-response.model"; export * from "./call-valid-headers.model"; export * from "./get-user-friends-account-ids-response.model"; +export * from "./membership.model"; export * from "./profile-from-account-id-response.model"; export * from "./profile-from-user-name-response.model"; +export * from "./purchased-games-response.model"; export * from "./rarest-thin-trophy.model"; export * from "./recently-played-games-response.model"; export * from "./shareable-profile-link-response.model"; diff --git a/src/models/membership.model.ts b/src/models/membership.model.ts new file mode 100644 index 0000000..04eec5b --- /dev/null +++ b/src/models/membership.model.ts @@ -0,0 +1 @@ +export type Membership = "NONE" | "PS_PLUS"; diff --git a/src/models/purchased-games-response.model.ts b/src/models/purchased-games-response.model.ts new file mode 100644 index 0000000..7a11b0c --- /dev/null +++ b/src/models/purchased-games-response.model.ts @@ -0,0 +1,52 @@ +import { Membership } from "./membership.model"; +import { TitlePlatform } from "./title-platform.model"; + +export interface PurchasedGame { + /** GraphQL object type/schema */ + __typename: "GameLibraryTitle"; + + /** Unique concept identifier for the game */ + conceptId: string | null; + + /** Unique entitlement identifier */ + entitlementId: string; + + /** Contains a url to a game icon file */ + image: { + __typename: "Media"; + url: string; + }; + + /** Whether the game is currently active */ + isActive: boolean; + + /** Whether the game is downloadable */ + isDownloadable: boolean; + + /** Whether the game is a pre-order */ + isPreOrder: boolean; + + /** The membership level associated with this game */ + membership: Membership; + + /** The name of the game */ + name: string; + + /** The platform this game is available on */ + platform: TitlePlatform; + + /** Unique product identifier */ + productId: string; + + /** Unique title identifier */ + titleId: string; +} + +export interface PurchasedGamesResponse { + data: { + purchasedTitlesRetrieve: { + __typename: "GameList"; + games: PurchasedGame[]; + }; + }; +} diff --git a/src/models/user-devices-response.model.ts b/src/models/user-devices-response.model.ts index b59a7b2..60a56ed 100644 --- a/src/models/user-devices-response.model.ts +++ b/src/models/user-devices-response.model.ts @@ -13,7 +13,7 @@ export interface AccountDevicesResponse { */ deviceType: string; - /** + /** * The activation type. * @example "PRIMARY" | "PSN_GAME_V3" */ diff --git a/src/user/getAccountDevices.test.ts b/src/user/getAccountDevices.test.ts index b3a9622..af83e8f 100644 --- a/src/user/getAccountDevices.test.ts +++ b/src/user/getAccountDevices.test.ts @@ -73,7 +73,9 @@ describe("Function: getAccountDevices", () => { expect(response).toEqual(mockResponse); expect(response.accountDevices).toHaveLength(3); expect(response.accountDevices[0].deviceType).toBe("PS5"); - expect(response.accountDevices[0].activationType).toBe(MOCK_ACTIVATION_PRIMARY); + expect(response.accountDevices[0].activationType).toBe( + MOCK_ACTIVATION_PRIMARY + ); expect(response.accountDevices[1].deviceType).toBe("PS4"); expect(response.accountDevices[2].deviceType).toBe("PSVita"); }); @@ -146,7 +148,9 @@ describe("Function: getAccountDevices", () => { // ASSERT expect(response.accountDevices).toHaveLength(1); expect(response.accountDevices[0].deviceType).toBe("PS5"); - expect(response.accountDevices[0].activationType).toBe(MOCK_ACTIVATION_PRIMARY); + expect(response.accountDevices[0].activationType).toBe( + MOCK_ACTIVATION_PRIMARY + ); expect(response.accountDevices[0].deviceId).toBe("ps5-primary-device"); }); @@ -397,8 +401,7 @@ describe("Function: getAccountDevices", () => { .get(`${basePath}/v1/devices/accounts/me`) .query((query) => { return ( - query.includeFields === INCLUDE_FIELDS && - query.platform === PLATFORM + query.includeFields === INCLUDE_FIELDS && query.platform === PLATFORM ); }) .reply(200, mockResponse); diff --git a/website/docs/api-docs/data-models/membership.md b/website/docs/api-docs/data-models/membership.md new file mode 100644 index 0000000..658ad8d --- /dev/null +++ b/website/docs/api-docs/data-models/membership.md @@ -0,0 +1,8 @@ +# Membership + +The `Membership` type represents the membership level associated with a game or user account. + +| Label | Value | Description | +| :-------- | :---------- | :---------------------------------------------------- | +| `NONE` | `"NONE"` | No membership subscription associated with the game. | +| `PS_PLUS` | `"PS_PLUS"` | PlayStation Plus membership associated with the game. | diff --git a/website/docs/api-docs/data-models/purchased-game.md b/website/docs/api-docs/data-models/purchased-game.md new file mode 100644 index 0000000..718cdeb --- /dev/null +++ b/website/docs/api-docs/data-models/purchased-game.md @@ -0,0 +1,15 @@ +# PurchasedGame + +| Name | Type | Description | +| :--------------- | :------------------------------------------------------ | :----------------------------------------------------------------------- | +| `conceptId` | `string \| null` | Unique concept identifier for the game. Can be `null` for some entries. | +| `entitlementId` | `string` | Unique entitlement identifier for the purchased game. | +| `image.url` | `string` | Contains a URL to a game icon file. | +| `isActive` | `boolean` | Whether the game is currently active. | +| `isDownloadable` | `boolean` | Whether the game is downloadable. | +| `isPreOrder` | `boolean` | Whether the game is a pre-order. | +| `membership` | [`Membership`](/api-docs/data-models/membership) | The membership level associated with this game (e.g., PlayStation Plus). | +| `name` | `string` | The name of the game. | +| `platform` | [`TitlePlatform`](/api-docs/data-models/title-platform) | The platform this game is available on (PS4, PS5, etc.). | +| `productId` | `string` | Unique product identifier for the game. | +| `titleId` | `string` | Unique title identifier for the game. | diff --git a/website/docs/api-docs/data-models/title-platform.md b/website/docs/api-docs/data-models/title-platform.md new file mode 100644 index 0000000..c54019e --- /dev/null +++ b/website/docs/api-docs/data-models/title-platform.md @@ -0,0 +1,10 @@ +# TitlePlatform + +The `TitlePlatform` type represents the gaming platform a title is available on. + +| Label | Value | Description | +| :----- | :------- | :---------------------------------- | +| `PS5` | `"PS5"` | PlayStation 5 platform. | +| `PS4` | `"PS4"` | PlayStation 4 platform. | +| `PS3` | `"PS3"` | PlayStation 3 platform. | +| `Vita` | `"Vita"` | PlayStation Vita handheld platform. | diff --git a/website/docs/api-docs/users.md b/website/docs/api-docs/users.md index 186f54e..2adc199 100644 --- a/website/docs/api-docs/users.md +++ b/website/docs/api-docs/users.md @@ -302,6 +302,74 @@ These are the possible values that can be in the `options` object (the second pa --- +## getPurchasedGames + +A call to this function will retrieve purchased games for the user associated with the `accessToken` in +the provided [AuthorizationPayload](/api-docs/data-models/authorization-payload). This endpoint returns only PS4 and PS5 games. + +### Examples + +#### Get purchased games + +```ts +import { getPurchasedGames } from "psn-api"; + +const purchasedGames = await getPurchasedGames(authorization, { + platform: ["ps4", "ps5"], + size: 24, + sortBy: "ACTIVE_DATE", + sortDirection: "desc" +}); +``` + +#### Get purchased games with specific filters + +```ts +import { getPurchasedGames } from "psn-api"; + +const purchasedGames = await getPurchasedGames(authorization, { + isActive: true, + platform: ["ps5"], + size: 50, + start: 0, + sortBy: "ACTIVE_DATE", + sortDirection: "asc", + membership: "PS_PLUS" +}); +``` + +### Returns + +| Name | Type | Description | +| :----------------------------------- | :------------------------------------------------------ | :----------------------- | +| `data.purchasedTitlesRetrieve.games` | [PurchasedGame](/api-docs/data-models/purchased-game)[] | List of purchased games. | + +### Parameters + +| Name | Type | Description | +| :-------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- | +| `authorization` | [`AuthorizationPayload`](/api-docs/data-models/authorization-payload) | An object that must contain an `accessToken`. See [this page](/authentication/authenticating-manually) for how to get one. | + +### Options + +These are the possible values that can be in the `options` object (the second parameter of the function). + +| Name | Type | Description | +| :-------------- | :----------------------------------------------- | :------------------------------------------------------------- | +| `isActive` | `boolean` | Whether to include only active games. Defaults to `true`. | +| `platform` | ("ps4" | "ps5")[] | Array of platforms to filter by. Defaults to `["ps4", "ps5"]`. | +| `size` | `number` | Number of games to retrieve per page. Defaults to `24`. | +| `start` | `number` | Starting offset for pagination. Defaults to `0`. | +| `sortBy` | `"ACTIVE_DATE"` | Field to sort by. Defaults to `"ACTIVE_DATE"`. | +| `sortDirection` | "asc" | "desc" | Sort direction. Defaults to `"desc"`. | +| `membership` | [`Membership`](/api-docs/data-models/membership) | Filter by membership type. | + +### Source + +[graphql/getPurchasedGames.ts](https://github.com/achievements-app/psn-api/blob/main/src/graphql/getPurchasedGames.ts) + +--- + ## getUserPlayedGames A call to this function will retrieve a list of games (ordered by recently played) for a user associated with the `accountId` provided. diff --git a/website/docs/examples/user-trophy-list.md b/website/docs/examples/user-trophy-list.md index e4e245e..cd831c7 100644 --- a/website/docs/examples/user-trophy-list.md +++ b/website/docs/examples/user-trophy-list.md @@ -54,8 +54,9 @@ async function main() { title.npCommunicationId, "all", { - npServiceName: - title.trophyTitlePlatform.includes("PS5") ? undefined : "trophy" + npServiceName: title.trophyTitlePlatform.includes("PS5") + ? undefined + : "trophy" } ); @@ -66,8 +67,9 @@ async function main() { title.npCommunicationId, "all", { - npServiceName: - title.trophyTitlePlatform.includes("PS5") ? undefined : "trophy" + npServiceName: title.trophyTitlePlatform.includes("PS5") + ? undefined + : "trophy" } );