Skip to content
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

feat(products/search): add storefront search route / mock-functionality #209

Merged
merged 13 commits into from
Aug 28, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/dry-guests-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@labdigital/commercetools-mock": minor
---

Addition of "products/search" POST endpoint. Also known as "Storefront Search -> Product Search".
96 changes: 96 additions & 0 deletions src/lib/searchQueryTypeChecker.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
120 changes: 120 additions & 0 deletions src/lib/searchQueryTypeChecker.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/product-projection-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
123 changes: 123 additions & 0 deletions src/product-search.ts
Original file line number Diff line number Diff line change
@@ -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 {
borisvankatwijk marked this conversation as resolved.
Show resolved Hide resolved
validateSearchQuery(params.query);
} catch (err) {
console.error(err);
throw new CommercetoolsError<InvalidInputError>(
{
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,
};
}
}
13 changes: 13 additions & 0 deletions src/repositories/product/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,28 @@ 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";
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 {
Expand Down Expand Up @@ -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);
}
}
Loading