-
Notifications
You must be signed in to change notification settings - Fork 52
feat: add getSearchResults endpoint #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e68a2d3
8d326ec
b26a7a1
a974f9c
da85b5c
c69a07d
6dbf831
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| ); | ||
| }); | ||
| }); |
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DX: We should document the expected format of these values. The What if someone passes
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Every other endpoint takes Since
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| * | ||
| * @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)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DX:
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 })
};
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| export * from "./getPurchasedGames"; | ||
| export * from "./getRecentlyPlayedGames"; | ||
| export * from "./getSearchResults"; |
| 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[]; | ||
| }; | ||
| }; | ||
| } |
There was a problem hiding this comment.
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
nextCursorandpageOffset.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done 👍