diff --git a/.changeset/dry-guests-mate.md b/.changeset/dry-guests-mate.md new file mode 100644 index 00000000..16722bcf --- /dev/null +++ b/.changeset/dry-guests-mate.md @@ -0,0 +1,5 @@ +--- +"@labdigital/commercetools-mock": minor +--- + +Addition of "products/search" POST endpoint. Also known as "Storefront Search -> Product Search". diff --git a/src/lib/searchQueryTypeChecker.test.ts b/src/lib/searchQueryTypeChecker.test.ts new file mode 100644 index 00000000..0c25d05b --- /dev/null +++ b/src/lib/searchQueryTypeChecker.test.ts @@ -0,0 +1,96 @@ +import type { + SearchAndExpression, + SearchOrExpression, + _SearchQuery, + _SearchQueryExpression, +} from "@commercetools/platform-sdk"; +import { describe, expect, it } from "vitest"; +import { + isSearchAndExpression, + isSearchAnyValue, + isSearchExactExpression, + isSearchExistsExpression, + isSearchFilterExpression, + isSearchFullTextExpression, + isSearchFullTextPrefixExpression, + isSearchNotExpression, + isSearchOrExpression, + isSearchPrefixExpression, + isSearchRangeExpression, + isSearchWildCardExpression, + validateSearchQuery, +} from "./searchQueryTypeChecker"; + +describe("searchQueryTypeChecker", () => { + it("should validate SearchAndExpression", () => { + const query: SearchAndExpression = { and: [] }; + expect(isSearchAndExpression(query)).toBe(true); + }); + + it("should validate SearchOrExpression", () => { + const query: SearchOrExpression = { or: [] }; + expect(isSearchOrExpression(query)).toBe(true); + }); + + it("should validate SearchNotExpression", () => { + const query: _SearchQueryExpression = { not: {} }; + expect(isSearchNotExpression(query)).toBe(true); + }); + + it("should validate SearchFilterExpression", () => { + const query: _SearchQueryExpression = { filter: [] }; + expect(isSearchFilterExpression(query)).toBe(true); + }); + + it("should validate SearchRangeExpression", () => { + const query: _SearchQueryExpression = { range: {} }; + expect(isSearchRangeExpression(query)).toBe(true); + }); + + it("should validate SearchExactExpression", () => { + const query: _SearchQueryExpression = { exact: "some-exact" }; + expect(isSearchExactExpression(query)).toBe(true); + }); + + it("should validate SearchExistsExpression", () => { + const query: _SearchQueryExpression = { exists: true }; + expect(isSearchExistsExpression(query)).toBe(true); + }); + + it("should validate SearchFullTextExpression", () => { + const query: _SearchQueryExpression = { fullText: "some-text" }; + expect(isSearchFullTextExpression(query)).toBe(true); + }); + + it("should validate SearchFullTextPrefixExpression", () => { + const query: _SearchQueryExpression = { fullTextPrefix: "some-prefix" }; + expect(isSearchFullTextPrefixExpression(query)).toBe(true); + }); + + it("should validate SearchPrefixExpression", () => { + const query: _SearchQueryExpression = { prefix: "some-prefix" }; + expect(isSearchPrefixExpression(query)).toBe(true); + }); + + it("should validate SearchWildCardExpression", () => { + const query: _SearchQueryExpression = { wildcard: "some-wildcard" }; + expect(isSearchWildCardExpression(query)).toBe(true); + }); + + it("should validate SearchAnyValue", () => { + const query: _SearchQueryExpression = { value: "some-value" }; + expect(isSearchAnyValue(query)).toBe(true); + }); + + it("should throw an error for unsupported query", () => { + const query = { unsupported: "unsupported" } as _SearchQuery; + expect(() => validateSearchQuery(query)).toThrow( + "Unsupported search query expression", + ); + }); + + it("should not throw an error for supported query", () => { + const query: SearchAndExpression = { and: [] }; + expect(() => validateSearchQuery(query)).not.toThrow(); + }); +}); diff --git a/src/lib/searchQueryTypeChecker.ts b/src/lib/searchQueryTypeChecker.ts new file mode 100644 index 00000000..05383ff5 --- /dev/null +++ b/src/lib/searchQueryTypeChecker.ts @@ -0,0 +1,120 @@ +import type { + SearchAndExpression, + SearchAnyValue, + SearchDateRangeExpression, + SearchDateTimeRangeExpression, + SearchExactExpression, + SearchExistsExpression, + SearchFilterExpression, + SearchFullTextExpression, + SearchFullTextPrefixExpression, + SearchLongRangeExpression, + SearchNotExpression, + SearchNumberRangeExpression, + SearchOrExpression, + SearchPrefixExpression, + SearchTimeRangeExpression, + SearchWildCardExpression, + _SearchQuery, + _SearchQueryExpression, +} from "@commercetools/platform-sdk"; + +export const validateSearchQuery = (query: _SearchQuery): void => { + if (isSearchAndExpression(query)) { + query.and.forEach((expr) => validateSearchQuery(expr)); + } else if (isSearchOrExpression(query)) { + query.or.forEach((expr) => validateSearchQuery(expr)); + } else if (isSearchNotExpression(query)) { + validateSearchQuery(query.not); + } else if ( + isSearchFilterExpression(query) || + isSearchRangeExpression(query) || + isSearchExactExpression(query) || + isSearchExistsExpression(query) || + isSearchFullTextExpression(query) || + isSearchFullTextPrefixExpression(query) || + isSearchPrefixExpression(query) || + isSearchWildCardExpression(query) || + isSearchAnyValue(query) + ) { + return; + } else { + throw new Error("Unsupported search query expression"); + } +}; + +// Type guards +export const isSearchAndExpression = ( + expr: _SearchQuery, +): expr is SearchAndExpression => + (expr as SearchAndExpression).and !== undefined; + +export const isSearchOrExpression = ( + expr: _SearchQuery, +): expr is SearchOrExpression => (expr as SearchOrExpression).or !== undefined; + +export type SearchRangeExpression = + | SearchDateRangeExpression + | SearchDateTimeRangeExpression + | SearchLongRangeExpression + | SearchNumberRangeExpression + | SearchTimeRangeExpression; + +// Type guard for SearchNotExpression +export const isSearchNotExpression = ( + expr: _SearchQueryExpression, +): expr is SearchNotExpression => + (expr as SearchNotExpression).not !== undefined; + +// Type guard for SearchFilterExpression +export const isSearchFilterExpression = ( + expr: _SearchQueryExpression, +): expr is SearchFilterExpression => + (expr as SearchFilterExpression).filter !== undefined; + +// Type guard for SearchDateRangeExpression +export const isSearchRangeExpression = ( + expr: _SearchQueryExpression, +): expr is SearchRangeExpression => + (expr as SearchRangeExpression).range !== undefined; + +// Type guard for SearchExactExpression +export const isSearchExactExpression = ( + expr: _SearchQueryExpression, +): expr is SearchExactExpression => + (expr as SearchExactExpression).exact !== undefined; + +// Type guard for SearchExistsExpression +export const isSearchExistsExpression = ( + expr: _SearchQueryExpression, +): expr is SearchExistsExpression => + (expr as SearchExistsExpression).exists !== undefined; + +// Type guard for SearchFullTextExpression +export const isSearchFullTextExpression = ( + expr: _SearchQueryExpression, +): expr is SearchFullTextExpression => + (expr as SearchFullTextExpression).fullText !== undefined; + +// Type guard for SearchFullTextPrefixExpression +export const isSearchFullTextPrefixExpression = ( + expr: _SearchQueryExpression, +): expr is SearchFullTextPrefixExpression => + (expr as SearchFullTextPrefixExpression).fullTextPrefix !== undefined; + +// Type guard for SearchPrefixExpression +export const isSearchPrefixExpression = ( + expr: _SearchQueryExpression, +): expr is SearchPrefixExpression => + (expr as SearchPrefixExpression).prefix !== undefined; + +// Type guard for SearchWildCardExpression +export const isSearchWildCardExpression = ( + expr: _SearchQueryExpression, +): expr is SearchWildCardExpression => + (expr as SearchWildCardExpression).wildcard !== undefined; + +// Type guard for SearchAnyValue +export const isSearchAnyValue = ( + expr: _SearchQueryExpression, +): expr is SearchAnyValue => (expr as SearchAnyValue).value !== undefined; diff --git a/src/product-projection-search.ts b/src/product-projection-search.ts index e4905018..dc328901 100644 --- a/src/product-projection-search.ts +++ b/src/product-projection-search.ts @@ -77,7 +77,7 @@ export class ProductProjectionSearch { currency: params.priceCurrency, }); - // Apply filters pre facetting + // Apply filters pre faceting if (params.filter) { try { const filters = params.filter.map(parseFilterExpression); diff --git a/src/product-search.ts b/src/product-search.ts new file mode 100644 index 00000000..b8c72c86 --- /dev/null +++ b/src/product-search.ts @@ -0,0 +1,123 @@ +import { + InvalidInputError, + Product, + ProductPagedSearchResponse, + ProductProjection, + ProductSearchRequest, + ProductSearchResult, +} from "@commercetools/platform-sdk"; +import { CommercetoolsError } from "./exceptions"; +import { validateSearchQuery } from "./lib/searchQueryTypeChecker"; +import { applyPriceSelector } from "./priceSelector"; +import { AbstractStorage } from "./storage"; + +export class ProductSearch { + protected _storage: AbstractStorage; + + constructor(storage: AbstractStorage) { + this._storage = storage; + } + + search( + projectKey: string, + params: ProductSearchRequest, + ): ProductPagedSearchResponse { + const resources = this._storage + .all(projectKey, "product") + .map((r) => + this.transform(r, params.productProjectionParameters?.staged ?? false), + ) + .filter((p) => { + if (!params.productProjectionParameters?.staged ?? false) { + return p.published; + } + return true; + }); + + // Validate query, if given + if (params.query) { + try { + validateSearchQuery(params.query); + } catch (err) { + console.error(err); + throw new CommercetoolsError( + { + code: "InvalidInput", + message: (err as any).message, + }, + 400, + ); + } + } + + // Apply the priceSelector + if (params.productProjectionParameters) { + applyPriceSelector(resources, { + country: params.productProjectionParameters.priceCountry, + channel: params.productProjectionParameters.priceChannel, + customerGroup: params.productProjectionParameters.priceCustomerGroup, + currency: params.productProjectionParameters.priceCurrency, + }); + } + + // @TODO: Determine whether or not to spoof search, facet filtering, wildcard, boosting and/or sorting. + // For now this is deliberately not supported. + + const offset = params.offset || 0; + const limit = params.limit || 20; + const productProjectionsResult = resources.slice(offset, offset + limit); + + /** + * Do not supply productProjection if productProjectionParameters are not given + * https://docs.commercetools.com/api/projects/product-search#with-product-projection-parameters + */ + const productProjectionsParameterGiven = + !!params?.productProjectionParameters; + + // Transform to ProductSearchResult + const results: ProductSearchResult[] = productProjectionsResult.map( + (product) => ({ + productProjection: productProjectionsParameterGiven + ? product + : undefined, + id: product.id, + /** + * @TODO: possibly add support for optional matchingVariants + * https://docs.commercetools.com/api/projects/product-search#productsearchmatchingvariants + */ + }), + ); + + return { + total: resources.length, + offset: offset, + limit: limit, + results: results, + facets: [], + }; + } + + transform(product: Product, staged: boolean): ProductProjection { + const obj = !staged + ? product.masterData.current + : product.masterData.staged; + + return { + id: product.id, + createdAt: product.createdAt, + lastModifiedAt: product.lastModifiedAt, + version: product.version, + name: obj.name, + key: product.key, + description: obj.description, + metaDescription: obj.metaDescription, + slug: obj.slug, + categories: obj.categories, + masterVariant: obj.masterVariant, + variants: obj.variants, + productType: product.productType, + hasStagedChanges: product.masterData.hasStagedChanges, + published: product.masterData.published, + }; + } +} diff --git a/src/repositories/product/index.ts b/src/repositories/product/index.ts index 9b7a6cea..a7613591 100644 --- a/src/repositories/product/index.ts +++ b/src/repositories/product/index.ts @@ -4,12 +4,15 @@ import type { Product, ProductData, ProductDraft, + ProductPagedSearchResponse, + ProductSearchRequest, ProductTypeReference, StateReference, TaxCategoryReference, } from "@commercetools/platform-sdk"; import { CommercetoolsError } from "~src/exceptions"; import { getBaseResourceProperties } from "~src/helpers"; +import { ProductSearch } from "~src/product-search"; import { AbstractStorage } from "~src/storage/abstract"; import { AbstractResourceRepository, RepositoryContext } from "../abstract"; import { getReferenceFromResourceIdentifier } from "../helpers"; @@ -17,9 +20,12 @@ import { ProductUpdateHandler } from "./actions"; import { variantFromDraft } from "./helpers"; export class ProductRepository extends AbstractResourceRepository<"product"> { + protected _searchService: ProductSearch; + constructor(storage: AbstractStorage) { super("product", storage); this.actions = new ProductUpdateHandler(storage); + this._searchService = new ProductSearch(storage); } create(context: RepositoryContext, draft: ProductDraft): Product { @@ -127,4 +133,11 @@ export class ProductRepository extends AbstractResourceRepository<"product"> { return this.saveNew(context, resource); } + + search( + context: RepositoryContext, + searchRequest: ProductSearchRequest, + ): ProductPagedSearchResponse { + return this._searchService.search(context.projectKey, searchRequest); + } } diff --git a/src/services/product.test.ts b/src/services/product.test.ts index 15662804..dab0471e 100644 --- a/src/services/product.test.ts +++ b/src/services/product.test.ts @@ -1,4 +1,4 @@ -import type { +import { Category, CategoryDraft, Image, @@ -6,6 +6,9 @@ import type { Product, ProductData, ProductDraft, + ProductPagedSearchResponse, + ProductSearchRequest, + ProductSearchResult, ProductType, ProductTypeDraft, State, @@ -1485,4 +1488,76 @@ describe("Product update actions", () => { ?.myCustomField, ).toBe("MyRandomValue"); }); + + // Test the general product search implementation + describe("Product Search - Generic", () => { + test("Pagination", async () => { + { + const body: ProductSearchRequest = { + productProjectionParameters: { + storeProjection: "dummy-store", + localeProjection: ["en-US"], + priceCurrency: "EUR", + priceChannel: "dummy-channel", + expand: ["categories[*]", "categories[*].ancestors[*]"], + }, + limit: 24, + }; + const response = await supertest(ctMock.app) + .post("/dummy/products/search") + .send(body); + + const pagedSearchResponse: ProductPagedSearchResponse = response.body; + expect(pagedSearchResponse.limit).toBe(24); + expect(pagedSearchResponse.offset).toBe(0); + expect(pagedSearchResponse.total).toBeGreaterThan(0); + + // Deliberately not supported fow now + expect(pagedSearchResponse.facets).toEqual([]); + + const results: ProductSearchResult[] = pagedSearchResponse.results; + expect(results).toBeDefined(); + expect(results.length).toBeGreaterThan(0); + + // Find product with sku "1337" to be part of the search results + const productFound = results.find( + (result) => result?.productProjection?.masterVariant?.sku === "1337", + ); + expect(productFound).toBeDefined(); + + const priceCurrencyMatch = results.find((result) => + result?.productProjection?.masterVariant?.prices?.find( + (price) => price?.value?.currencyCode === "EUR", + ), + ); + expect(priceCurrencyMatch).toBeDefined(); + } + { + const body: ProductSearchRequest = { + limit: 88, + offset: 88, + }; + + const response = await supertest(ctMock.app) + .post("/dummy/products/search") + .send(body); + + const pagedSearchResponse: ProductPagedSearchResponse = response.body; + expect(pagedSearchResponse.limit).toBe(88); + expect(pagedSearchResponse.offset).toBe(88); + expect(pagedSearchResponse.total).toBeGreaterThan(0); + + // No results, since we start at offset 88 + const results: ProductSearchResult[] = pagedSearchResponse.results; + expect(results).toBeDefined(); + expect(results.length).toBe(0); + + // Product with sku "1337" should not be part of the results + const productFound = results.find( + (result) => result?.productProjection?.masterVariant?.sku === "1337", + ); + expect(productFound).toBeUndefined(); + } + }); + }); }); diff --git a/src/services/product.ts b/src/services/product.ts index 265b7407..725a9db3 100644 --- a/src/services/product.ts +++ b/src/services/product.ts @@ -1,4 +1,5 @@ -import { Router } from "express"; +import { Request, Response, Router } from "express"; +import { getRepositoryContext } from "~src/repositories/helpers"; import { ProductRepository } from "../repositories/product"; import AbstractService from "./abstract"; @@ -13,4 +14,17 @@ export class ProductService extends AbstractService { getBasePath() { return "products"; } + + extraRoutes(router: Router) { + router.post("/search", this.search.bind(this)); + } + + search(request: Request, response: Response) { + const searchBody = request.body; + const resource = this.repository.search( + getRepositoryContext(request), + searchBody, + ); + return response.status(200).send(resource); + } } diff --git a/src/types.ts b/src/types.ts index de31c055..822f020c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,11 +10,11 @@ export type ShallowWritable = { -readonly [P in keyof T]: T[P] }; export type ServiceTypes = | ctp.ReferenceTypeId | "product-projection" + | "product-search" | "my-cart" | "my-order" | "my-payment" - | "my-customer" - | "product-projection"; + | "my-customer"; export type Services = Partial<{ [index in ServiceTypes]: AbstractService;