diff --git a/.gitignore b/.gitignore index 096852c..e7771b0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ coverage !.vscode/extensions.json !.vscode/settings.json +# IntelliJ IDEA +.idea/ + # Docusaurus website/node_modules website/.docusaurus diff --git a/README.md b/README.md index 44765ea..f426647 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Click the function names to open their complete docs on the docs site. ### Search - [`makeUniversalSearch()`](https://psn-api.achievements.app/api-docs/universal-search#makeuniversalsearch) - Search the PSN API. This is a good way to find a user's `accountId` from their username. +- [`getSearchResults()`](https://psn-api.achievements.app/api-docs/universal-search#getsearchresults) - Search for games, DLC, and other products on the PlayStation Store. ### Users diff --git a/src/graphql/getSearchResults.test.ts b/src/graphql/getSearchResults.test.ts new file mode 100644 index 0000000..27df64b --- /dev/null +++ b/src/graphql/getSearchResults.test.ts @@ -0,0 +1,205 @@ +import nock from "nock"; + +import type { SearchResultsResponse } from "../models"; +import { getSearchResults } from "./getSearchResults"; +import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL"; + +describe("Function: getSearchResults", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("is defined #sanity", () => { + // ASSERT + expect(getSearchResults).toBeDefined(); + }); + + it("retrieves search results for a given search term", async () => { + // ARRANGE + const mockResponse: SearchResultsResponse = { + data: { + universalSearch: { + __typename: "UniversalSearchResponse", + next: "CDAaVQolYjg5ODE2ODk0Y2ZiNDU2ZGIzNTU5MjIwOWM5YTdjODQtNDAxNxIsc2VhcmNoLXJlbGV2YW5jeS1jb25jZXB0LWdhbWUtYWxsLXRvcGstYXN0cmEiHnNlYXJjaC5ub19leHBlcmltZW50Lm5vbi4wLm5vbioDNzUx", + pageInfo: { + __typename: "PageInfo", + isLast: false, + offset: 0, + size: 2, + totalCount: 87 + }, + results: [ + { + __typename: "Product", + id: "EP1018-CUSA00135_00-BAKPREORDER00001", + localizedStoreDisplayClassification: "Volledige game", + media: [ + { + __typename: "Media", + role: "MASTER", + type: "IMAGE", + url: "https://image.api.playstation.com/cdn/EP1018/CUSA00135_00/gIBLibuNu1I91g9FYzkqBJFLMd1X9OaD.png" + } + ], + name: "Batman: Arkham Knight", + npTitleId: "CUSA00135_00", + personalizedMeta: { + __typename: "PersonalizedMeta", + hasMediaOverrides: false, + media: [ + { + __typename: "Media", + role: "MASTER", + type: "IMAGE", + url: "https://image.api.playstation.com/cdn/EP1018/CUSA00135_00/gIBLibuNu1I91g9FYzkqBJFLMd1X9OaD.png" + } + ] + }, + platforms: ["PS4"], + price: { + __typename: "SkuPrice", + skuId: "EP1018-CUSA00135_00-BAKPREORDER00001-E005", + basePrice: "€19.99", + discountText: null, + discountedPrice: null, + includesBundleOffer: null, + isExclusive: false, + isFree: false, + isTiedToSubscription: null, + serviceBranding: [], + upsellServiceBranding: [], + upsellText: null + }, + skus: [ + { + __typename: "Sku", + type: "STANDARD" + } + ], + storeDisplayClassification: "FULL_GAME" + }, + { + __typename: "Product", + id: "EP1018-CUSA00135_00-DLCTWOFACESTORY0", + localizedStoreDisplayClassification: "Level", + media: [ + { + __typename: "Media", + role: "MASTER", + type: "IMAGE", + url: "https://image.api.playstation.com/cdn/EP1018/CUSA00135_00/U2Yf57C14ovXI3zl3uXWoHSli3SywqEr.png" + } + ], + name: "Batman™: Arkham Knight Kop of munt", + npTitleId: "CUSA00135_00", + personalizedMeta: { + __typename: "PersonalizedMeta", + hasMediaOverrides: false, + media: [ + { + __typename: "Media", + role: "MASTER", + type: "IMAGE", + url: "https://image.api.playstation.com/cdn/EP1018/CUSA00135_00/U2Yf57C14ovXI3zl3uXWoHSli3SywqEr.png" + } + ] + }, + platforms: ["PS4"], + price: { + __typename: "SkuPrice", + skuId: "EP1018-CUSA00135_00-DLCTWOFACESTORY0-E001", + basePrice: "€1.99", + discountText: null, + discountedPrice: null, + includesBundleOffer: null, + isExclusive: false, + isFree: false, + isTiedToSubscription: null, + serviceBranding: [], + upsellServiceBranding: [], + upsellText: null + }, + skus: [ + { + __typename: "Sku", + type: "STANDARD" + } + ], + storeDisplayClassification: "LEVEL" + } + ] + } + } + }; + + const baseUrlObj = new URL(GRAPHQL_BASE_URL); + const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + const basePath = baseUrlObj.pathname; + + const cursor = "CDAaVQolYjg5ODE2ODk0Y2ZiNDU2ZGIzNTU5MjIwOWM5YTdjODQ"; + const expectedVariables = JSON.stringify({ + countryCode: "NL", + languageCode: "nl", + searchTerm: "batman", + pageSize: 2, + pageOffset: 0, + nextCursor: cursor + }); + const expectedExtensions = JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: + "6ef5e809c35a056a1150fdcf513d9c505484dd1a946b6208888435c3182f105a" + } + }); + + // ... we need to use a nock matcher to verify the query parameters ... + const mockScope = nock(baseUrl) + .get(basePath) + .query((params) => { + expect(params.operationName).toEqual("getSearchResults"); + expect(params.variables).toEqual(expectedVariables); + expect(params.extensions).toEqual(expectedExtensions); + return true; + }) + .reply(200, mockResponse); + + // ACT + const response = await getSearchResults("batman", { + countryCode: "NL", + languageCode: "nl", + pageSize: 2, + pageOffset: 0, + nextCursor: cursor + }); + + // ASSERT + expect(response).toEqual(mockResponse); + expect(mockScope.isDone()).toBeTruthy(); + }); + + it("throws an error if we receive a malformed response", async () => { + // ARRANGE + const mockResponse = { + // This response occurs if the query/hash is not what the server expected. + message: + "Query 6ef5e809c35a056a1150fdcf513d9c505484dd1a946b6208888435c3182f105a not whitelisted" + }; + + const baseUrlObj = new URL(GRAPHQL_BASE_URL); + const baseUrl = `${baseUrlObj.protocol}//${baseUrlObj.host}`; + const basePath = baseUrlObj.pathname; + + nock(baseUrl).get(basePath).query(true).reply(200, mockResponse); + + // ASSERT + await expect( + getSearchResults("test", { + countryCode: "US", + languageCode: "en" + }) + ).rejects.toThrow( + "Query 6ef5e809c35a056a1150fdcf513d9c505484dd1a946b6208888435c3182f105a not whitelisted" + ); + }); +}); diff --git a/src/graphql/getSearchResults.ts b/src/graphql/getSearchResults.ts new file mode 100644 index 0000000..7e1cd6a --- /dev/null +++ b/src/graphql/getSearchResults.ts @@ -0,0 +1,72 @@ +import type { SearchResultsResponse } from "../models"; +import { call } from "../utils/call"; +import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL"; +import { getSearchResultsHash } from "./operationHashes"; + +type GetSearchResultsOptions = { + countryCode: string; + languageCode: string; + pageSize?: number; + pageOffset?: number; + nextCursor?: string; +}; + +/** + * A call to this function will retrieve search results from the PlayStation Store + * based on the provided search term and pagination. + * + * NOTE: This endpoint should be called WITHOUT authorization to receive price information. + * When called with authorization, price data may be filtered or unavailable. + * + * @param searchTerm The search term to query for. + * @param options An object containing search options. + * @param options.countryCode Two-letter country code (e.g., `"US"`, `"GB"`, `"NL"`) to determine region and pricing. + * @param options.languageCode Two-letter language code (e.g., `"en"`, `"es"`, `"nl"`) for localized content. + * @param options.pageSize _(Optional)_ Number of results per page. + * @param options.pageOffset _(Optional)_ Offset for pagination (0 for first page). + * @param options.nextCursor _(Optional)_ Pagination cursor. + */ +export const getSearchResults = async ( + searchTerm: string, + options: GetSearchResultsOptions +): Promise => { + const { countryCode, languageCode, pageSize, pageOffset, nextCursor } = options; + + const url = new URL(GRAPHQL_BASE_URL); + + url.searchParams.set("operationName", "getSearchResults"); + + const variables: Record = { + countryCode, + languageCode, + searchTerm, + ...(pageSize !== undefined && { pageSize }), + ...(pageOffset !== undefined && { pageOffset }), + ...(nextCursor !== undefined && { nextCursor }) + }; + + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set( + "extensions", + JSON.stringify({ + persistedQuery: { + version: 1, + sha256Hash: getSearchResultsHash + } + }) + ); + + const response = await call({ + url: url.toString(), + headers: { + "Accept-Language": `${languageCode}-${countryCode}` + } + }); + + // The GraphQL queries can return non-truthy values. + if (!response.data || !response.data.universalSearch) { + throw new Error(JSON.stringify(response)); + } + + return response; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index a7da725..10d23bb 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -1,2 +1,3 @@ export * from "./getPurchasedGames"; export * from "./getRecentlyPlayedGames"; +export * from "./getSearchResults"; diff --git a/src/graphql/operationHashes.ts b/src/graphql/operationHashes.ts index 5567e2f..d092add 100644 --- a/src/graphql/operationHashes.ts +++ b/src/graphql/operationHashes.ts @@ -22,3 +22,6 @@ export const getUserGameListHash = export const getPurchasedGameListHash = "827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168"; + +export const getSearchResultsHash = + "6ef5e809c35a056a1150fdcf513d9c505484dd1a946b6208888435c3182f105a"; diff --git a/src/models/index.ts b/src/models/index.ts index 59eb182..8361a66 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -12,6 +12,7 @@ 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 "./search-results-response.model"; export * from "./shareable-profile-link-response.model"; export * from "./social-account-result.model"; export * from "./title-platform.model"; diff --git a/src/models/search-results-response.model.ts b/src/models/search-results-response.model.ts new file mode 100644 index 0000000..c630ce3 --- /dev/null +++ b/src/models/search-results-response.model.ts @@ -0,0 +1,65 @@ +export interface SearchResultMedia { + __typename: "Media"; + role: string; + type: "IMAGE" | "VIDEO"; + url: string; +} + +export interface SearchResultPersonalizedMeta { + __typename: "PersonalizedMeta"; + hasMediaOverrides: boolean; + media: SearchResultMedia[]; +} + +export interface SearchResultSkuPrice { + __typename: "SkuPrice"; + skuId: string; + basePrice: string | null; + discountText: string | null; + discountedPrice: string | null; + includesBundleOffer: boolean | null; + isExclusive: boolean; + isFree: boolean; + isTiedToSubscription: boolean | null; + serviceBranding: string[]; + upsellServiceBranding: string[]; + upsellText: string | null; +} + +export interface SearchResultSku { + __typename: "Sku"; + type: string; +} + +export interface SearchResultProduct { + __typename: "Product"; + id: string; + name: string; + npTitleId: string; + platforms: string[]; + localizedStoreDisplayClassification: string; + storeDisplayClassification: string; + media: SearchResultMedia[]; + personalizedMeta: SearchResultPersonalizedMeta; + price: SearchResultSkuPrice; + skus: SearchResultSku[]; +} + +export interface SearchResultPageInfo { + __typename: "PageInfo"; + isLast: boolean; + offset: number; + size: number; + totalCount: number; +} + +export interface SearchResultsResponse { + data: { + universalSearch: { + __typename: "UniversalSearchResponse"; + next: string | null; + pageInfo: SearchResultPageInfo; + results: SearchResultProduct[]; + }; + }; +} diff --git a/src/utils/call.ts b/src/utils/call.ts index 53df58a..251fb16 100644 --- a/src/utils/call.ts +++ b/src/utils/call.ts @@ -6,16 +6,21 @@ export const call = async ( method?: "GET" | "POST"; headers?: CallValidHeaders; }, - authorization: AuthorizationPayload, + authorization?: AuthorizationPayload, bodyPayload?: Record ) => { + const headers: Record = { + "Content-Type": "application/json", + ...config.headers + }; + + if (authorization?.accessToken) { + headers.Authorization = `Bearer ${authorization.accessToken}`; + } + const response = await fetch(config.url, { method: config?.method ?? "GET", - headers: { - Authorization: `Bearer ${authorization.accessToken}`, - "Content-Type": "application/json", - ...config?.headers - }, + headers, body: JSON.stringify(bodyPayload) }); diff --git a/website/docs/api-docs/data-models/search-results-response.md b/website/docs/api-docs/data-models/search-results-response.md new file mode 100644 index 0000000..c4bd0fc --- /dev/null +++ b/website/docs/api-docs/data-models/search-results-response.md @@ -0,0 +1,71 @@ +# SearchResultsResponse + +## SearchResultsResponse + +| Name | Type | Description | +| :------------------------------ | :---------------------------------------------- | :---------------------------------------------- | +| `data.universalSearch` | `object` | The main search results container. | +| `data.universalSearch.next` | `string \| null` | Pagination cursor for the next page of results. | +| `data.universalSearch.pageInfo` | [`SearchResultPageInfo`](#searchresultpageinfo) | Pagination information. | +| `data.universalSearch.results` | [`SearchResultProduct[]`](#searchresultproduct) | Array of product results. | + +## SearchResultPageInfo + +| Name | Type | Description | +| :----------- | :-------- | :---------------------------------------- | +| `isLast` | `boolean` | Whether this is the last page of results. | +| `offset` | `number` | Current offset in the result set. | +| `size` | `number` | Number of results in the current page. | +| `totalCount` | `number` | Total number of results available. | + +## SearchResultProduct + +| Name | Type | Description | +| :------------------------------------ | :-------------------------------------------------------------- | :------------------------------------------------------- | +| `id` | `string` | Unique product identifier. | +| `name` | `string` | Product name. | +| `npTitleId` | `string` | PlayStation Network title ID. | +| `platforms` | `string[]` | Array of platforms (e.g., `["PS4"]`, `["PS5"]`). | +| `localizedStoreDisplayClassification` | `string` | Localized product type (e.g., `"Full Game"`, `"Level"`). | +| `storeDisplayClassification` | `string` | Product classification (e.g., `"FULL_GAME"`, `"LEVEL"`). | +| `media` | [`SearchResultMedia[]`](#searchresultmedia) | Array of images and videos. | +| `personalizedMeta` | [`SearchResultPersonalizedMeta`](#searchresultpersonalizedmeta) | Personalized media content. | +| `price` | [`SearchResultSkuPrice`](#searchresultskuprice) | Pricing information. | +| `skus` | [`SearchResultSku[]`](#searchresultsku) | Available SKUs for the product. | + +## SearchResultMedia + +| Name | Type | Description | +| :----- | :------------------- | :----------------------------- | +| `role` | `string` | Media role (e.g., `"MASTER"`). | +| `type` | `"IMAGE" \| "VIDEO"` | Type of media content. | +| `url` | `string` | URL to the media file. | + +## SearchResultPersonalizedMeta + +| Name | Type | Description | +| :------------------ | :------------------------------------------ | :------------------------------------------ | +| `hasMediaOverrides` | `boolean` | Whether personalized media overrides exist. | +| `media` | [`SearchResultMedia[]`](#searchresultmedia) | Array of personalized media items. | + +## SearchResultSkuPrice + +| Name | Type | Description | +| :---------------------- |:------------------| :------------------------------------------------------------------------- | +| `skuId` | `string` | SKU identifier. | +| `basePrice` | `string \| null` | Base price with currency symbol (e.g. `"€19.99"` or `"Free"`). | +| `discountText` | `string \| null` | Discount description text. (e.g. `"-50%"`) | +| `discountedPrice` | `string \| null` | Discounted price with currency symbol. (e.g. `"€19.99"` or `"Free"`) | +| `includesBundleOffer` | `boolean \| null` | Whether a bundle offer is included. | +| `isExclusive` | `boolean` | Whether this is an exclusive product. | +| `isFree` | `boolean` | Whether the product is free. | +| `isTiedToSubscription` | `boolean \| null` | Whether tied to a subscription service. | +| `serviceBranding` | `string[]` | Service branding information. | +| `upsellServiceBranding` | `string[]` | Array of upsell service branding options. (e.g. `["NONE"]`, `["PS_PLUS"]`) | +| `upsellText` | `string \| null` | Upsell promotional text. (e.g. `"EXTRA"`) | + +## SearchResultSku + +| Name | Type | Description | +| :----- | :------- | :----------------------------- | +| `type` | `string` | SKU type (e.g., `"STANDARD"`). | diff --git a/website/docs/api-docs/universal-search.md b/website/docs/api-docs/universal-search.md index adfb5d9..0674d3a 100644 --- a/website/docs/api-docs/universal-search.md +++ b/website/docs/api-docs/universal-search.md @@ -7,10 +7,13 @@ sidebar_position: 2 ## makeUniversalSearch -A call to this function will make a universal search across the PlayStation Network for your search query. Each search query requires a domain, such as `"SocialAllAccounts"`. +A call to this function will make a universal search across the PlayStation Network for your search query. Each search +query requires a domain, such as `"SocialAllAccounts"`. :::note -If you search for your own username, it will not be in the list of results. This is a quirk of the universal search API. In cases where you want to use the `accountId` of the account that's currently logged in, use `"me"` instead of the standard account ID value. +If you search for your own username, it will not be in the list of results. This is a quirk of the universal search API. +In cases where you want to use the `accountId` of the account that's currently logged in, use `"me"` instead of the +standard account ID value. ::: ### Examples @@ -30,7 +33,7 @@ const response = await makeUniversalSearch( ### Returns | Name | Type | Description | -| :---------------- | :--------- | :---------- | +|:------------------|:-----------|:------------| | `prefix` | `string` | | | `suggestions` | `string[]` | | | `fallbackQueried` | `boolean` | | @@ -39,7 +42,7 @@ const response = await makeUniversalSearch( ### 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. | | `searchTerm` | `string` | The value being searched for. | | `domain` | `string` | What kind of value is being searched for, such as `SocialAllAccounts`. | @@ -47,3 +50,67 @@ const response = await makeUniversalSearch( ### Source [search/makeUniversalSearch.ts](https://github.com/achievements-app/psn-api/blob/main/src/search/makeUniversalSearch.ts) + +## getSearchResults + +A call to this function will retrieve search results from the PlayStation Store based on the provided search term. This +function searches for games, DLC, and other PlayStation Store products. + +:::warning +This endpoint should be called **WITHOUT** authorization to receive price information. When called with authorization, +price data may be filtered or unavailable. +::: + +### Examples + +#### Search for games in the PlayStation Store + +```ts +import { getSearchResults } from "psn-api"; + +const response = await getSearchResults("batman", { + countryCode: "US", + languageCode: "en", + pageSize: 24, + pageOffset: 0 +}); +``` + +### Returns + +[`SearchResultsResponse`](/api-docs/data-models/search-results-response) + +The response contains: + +| Name | Type | Description | +|:--------------------------------|:------------------------|:--------------------------------------------------| +| `data.universalSearch` | `object` | The main search results container | +| `data.universalSearch.next` | `string \| null` | Pagination cursor for the next page of results | +| `data.universalSearch.pageInfo` | `SearchResultPageInfo` | Pagination information (offset, size, totalCount) | +| `data.universalSearch.results` | `SearchResultProduct[]` | Array of product results with details and pricing | + +Each product result includes: + +- `id` - Product ID +- `name` - Product name +- `npTitleId` - PlayStation Network title ID +- `platforms` - Array of platforms (e.g., ["PS4", "PS5"]) +- `media` - Array of images and videos +- `price` - Pricing information including base price, discounts, etc. +- `storeDisplayClassification` - Product type (e.g., "FULL_GAME", "LEVEL") + +### Parameters + +| Name | Type | Description | +|:-----------------------|:---------|:----------------------------------------------------------------------------------| +| `searchTerm` | `string` | The search term to query for (e.g., game name, publisher). | +| `options` | `object` | Search options including country, language, and pagination settings. | +| `options.countryCode` | `string` | Two-letter country code (e.g., "US", "GB", "NL") to determine region and pricing. | +| `options.languageCode` | `string` | Two-letter language code (e.g., "en", "es", "nl") for localized content. | +| `options.pageSize` | `number` | _(Optional)_ Number of results per page. | +| `options.pageOffset` | `number` | _(Optional)_ Offset for pagination (0 for first page). | +| `options.nextCursor` | `string` | _(Optional)_ Pagination cursor. | + +### Source + +[graphql/getSearchResults.ts](https://github.com/achievements-app/psn-api/blob/main/src/graphql/getSearchResults.ts)