Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ coverage
!.vscode/extensions.json
!.vscode/settings.json

# IntelliJ IDEA
.idea/

# Docusaurus
website/node_modules
website/.docusaurus
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
205 changes: 205 additions & 0 deletions src/graphql/getSearchResults.test.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing: Recommend adding a test case which exercises nextCursor and pageOffset.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

Original file line number Diff line number Diff line change
@@ -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"
);
});
});
72 changes: 72 additions & 0 deletions src/graphql/getSearchResults.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +7 to +8
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DX: We should document the expected format of these values.

The Accept-Language spec uses subtags like en_US, nl-NL, etc.

What if someone passes countryCode: "US" and languageCode: "en-us"? Given there's no validation on these inputs, some sort of JSDoc here with expected format, probably via @example, would help.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. I've added a full description of the variables in the options object with example values.

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.
Comment on lines +18 to +19
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DX: This makes sense. What gives me pause is the function signature here is inconsistent with every other function in psn-api.

Every other endpoint takes authorization as the 1st param. It makes sense this one is intentionally different, but it may confuse a consumer unless they read this JSDoc. They may see:

getPurchasedGames(authorization, ...)
getRecentlyPlayedGames(authorization, ...)
getSearchResults(searchTerm, ...) // wait, what?

Since call() already handles authorization as optional, we could accept it as an optional 1st param for signature consistency. Consumers who want prices omit it, consumers who don't care pass it in. The JSDoc warning stays, but the API shape is now uniform.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, consistency would be nice here.

But making the first parameter optional would force the other parameters to be optional too, and that would make the usage very confusing in my opinion. On top of that the endpoint also returns an error if you do not send a searchTerm.

I could have it as a required parameter, and simply not pass it to call(), of course explaining that in the JSDoc. WDYT?

*
* @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<SearchResultsResponse> => {
const { countryCode, languageCode, pageSize, pageOffset, nextCursor } = options;

const url = new URL(GRAPHQL_BASE_URL);

url.searchParams.set("operationName", "getSearchResults");

const variables: Record<string, unknown> = {
countryCode,
languageCode,
searchTerm,
...(pageSize !== undefined && { pageSize }),
...(pageOffset !== undefined && { pageOffset }),
...(nextCursor !== undefined && { nextCursor })
};

url.searchParams.set("variables", JSON.stringify(variables));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DX: variables includes undefined values. We're destructuring pageSize, pageOffset, and nextCursor from options, but when they're not provided, we're stuffing undefined into variables and shipping it as JSON.

JSON.stringify() strips undefined values automagically, so this does technically work, but it means we're relying on a serialization side effect to clean up the variables data.

I think we should be more explicit about what we send. Maybe something like:

const variables: Record<string, unknown> = {
  countryCode,
  languageCode,
  searchTerm,
  ...(pageSize !== undefined && { pageSize }),
  ...(pageOffset !== undefined && { pageOffset }),
  ...(nextCursor !== undefined && { nextCursor })
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let's strip them then. I don't really see a "nicer" way to handle it than what you suggested, so I'll go with that.

url.searchParams.set(
"extensions",
JSON.stringify({
persistedQuery: {
version: 1,
sha256Hash: getSearchResultsHash
}
})
);

const response = await call<SearchResultsResponse>({
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;
};
1 change: 1 addition & 0 deletions src/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./getPurchasedGames";
export * from "./getRecentlyPlayedGames";
export * from "./getSearchResults";
3 changes: 3 additions & 0 deletions src/graphql/operationHashes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ export const getUserGameListHash =

export const getPurchasedGameListHash =
"827a423f6a8ddca4107ac01395af2ec0eafd8396fc7fa204aaf9b7ed2eefa168";

export const getSearchResultsHash =
"6ef5e809c35a056a1150fdcf513d9c505484dd1a946b6208888435c3182f105a";
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
65 changes: 65 additions & 0 deletions src/models/search-results-response.model.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
};
}
Loading