From a130aa66dd0e47ca215a95cf4b5fad27b1753525 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 15:17:44 +0300 Subject: [PATCH 01/10] synonym configuration --- src/SyncV2Sdk.php | 10 +++- .../ValueObjects/Response/SynonymResponse.php | 23 +++++++- .../Synonym/SynonymConfiguration.php | 37 +++++++++++- tests/SyncV2SdkTest.php | 33 +++++++++-- .../Response/SynonymResponseTest.php | 21 +++++++ .../Synonym/SynonymConfigurationTest.php | 57 +++++++++++++++++-- .../synonyms-ecommerce-en.json | 6 +- 7 files changed, 170 insertions(+), 17 deletions(-) diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index 03d425b..d4e7dac 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -216,7 +216,15 @@ public function getSynonyms(string $language): SynonymResponse $this->baseApiPath . 'synonyms?language=' . urlencode($language) ); - return SynonymResponse::fromArray($response); + // GET returns {language, synonyms} without count/reindex fields. + $synonyms = $response['synonyms'] ?? []; + + return SynonymResponse::fromArray([ + 'language' => $response['language'] ?? $language, + 'synonym_count' => $response['synonym_count'] ?? count($synonyms), + 'requires_reindex' => $response['requires_reindex'] ?? false, + 'synonyms' => $synonyms, + ]); } /** diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index 5d2c342..1e1ee0b 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -57,7 +57,28 @@ public static function fromArray(array $data): self language: (string) $data['language'], synonymCount: (int) $data['synonym_count'], requiresReindex: (bool) $data['requires_reindex'], - synonyms: $data['synonyms'] ?? null + synonyms: self::normalizeSynonyms($data['synonyms'] ?? null) + ); + } + + /** + * The API returns each synonym group as a Solr-format string + * ("laptop, notebook"); normalize those into term arrays. + * + * @param array>|null $synonyms + * @return array>|null + */ + private static function normalizeSynonyms(?array $synonyms): ?array + { + if ($synonyms === null) { + return null; + } + + return array_map( + static fn(string|array $group): array => is_string($group) + ? array_map('trim', explode(',', $group)) + : $group, + $synonyms ); } diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index 22814c2..488e57d 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -58,13 +58,35 @@ public function addSynonym(array $synonymGroup): self } /** + * Creates a SynonymConfiguration from an API response payload, where each + * synonym group is a Solr-format string (e.g. "laptop, notebook"). + * + * @param array $data + */ + public static function fromApiResponse(array $data): self + { + $synonyms = array_map( + static fn(string $group): array => array_map('trim', explode(',', $group)), + $data['synonyms'] ?? [] + ); + + return new self((string) ($data['language'] ?? ''), $synonyms); + } + + /** + * The API expects each synonym group as a Solr-format equivalence string + * ("laptop, notebook, computer"), not as a nested array. + * * @return array */ public function jsonSerialize(): array { return [ 'language' => $this->language, - 'synonyms' => $this->synonyms, + 'synonyms' => array_map( + static fn(array $group): string => implode(', ', $group), + $this->synonyms + ), ]; } @@ -150,6 +172,19 @@ private function validateSynonyms(array $synonyms): void $synonyms ); } + + if (str_contains($term, ',') || str_contains($term, '=>')) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', + $index, + $termIndex, + $term + ), + 'synonyms', + $synonyms + ); + } } } } diff --git a/tests/SyncV2SdkTest.php b/tests/SyncV2SdkTest.php index 65126f1..b5f31bf 100644 --- a/tests/SyncV2SdkTest.php +++ b/tests/SyncV2SdkTest.php @@ -967,13 +967,12 @@ public function testGetSynonymsSuccess(): void { $language = 'en'; + // GET returns the SynonymConfiguration shape: Solr strings, no count/reindex fields $apiResponse = [ 'language' => 'en', - 'synonym_count' => 2, - 'requires_reindex' => false, 'synonyms' => [ - ['happy', 'joyful', 'cheerful'], - ['sad', 'unhappy', 'sorrowful'], + 'happy, joyful, cheerful', + 'sad, unhappy, sorrowful', ], ]; @@ -991,7 +990,31 @@ public function testGetSynonymsSuccess(): void $this->assertEquals('en', $result->language); $this->assertEquals(2, $result->synonymCount); $this->assertFalse($result->requiresReindex); - $this->assertCount(2, $result->synonyms); + $this->assertEquals( + [ + ['happy', 'joyful', 'cheerful'], + ['sad', 'unhappy', 'sorrowful'], + ], + $result->synonyms + ); + } + + public function testGetSynonymsHandlesEmptyResult(): void + { + $httpClientMock = $this->createMock(HttpClient::class); + $httpClientMock + ->expects($this->once()) + ->method('get') + ->willReturn([ + 'language' => 'en', + 'synonyms' => null, + ]); + + $sdk = $this->createSdkWithMockedHttpClient($httpClientMock); + $result = $sdk->getSynonyms('en'); + + $this->assertEquals(0, $result->synonymCount); + $this->assertEquals([], $result->synonyms); } public function testGetSynonymsAppIdIncludedInUrlPath(): void diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index e6f7eae..6c9c90d 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -88,6 +88,27 @@ public function testFromArrayWithSynonyms(): void $this->assertEquals($synonyms, $response->synonyms); } + public function testFromArrayNormalizesSolrStringSynonymsIntoGroups(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 2, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, notebook,computer', + 'phone, mobile', + ], + ]); + + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile'], + ], + $response->synonyms + ); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php index a683540..be76ac8 100644 --- a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php +++ b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php @@ -138,7 +138,7 @@ public function testRejectsWhitespaceOnlyStringInSynonymGroup(): void new SynonymConfiguration('en', [[' ', 'laptop']]); } - public function testJsonSerializeReturnsCorrectStructure(): void + public function testJsonSerializeReturnsSolrFormatStrings(): void { $synonyms = [ ['laptop', 'notebook'], @@ -149,12 +149,51 @@ public function testJsonSerializeReturnsCorrectStructure(): void $expected = [ 'language' => 'en', - 'synonyms' => $synonyms, + 'synonyms' => [ + 'laptop, notebook', + 'phone, mobile', + ], ]; $this->assertEquals($expected, $config->jsonSerialize()); } + public function testRejectsCommaInSynonymTerm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain "," or "=>"'); + + new SynonymConfiguration('en', [['laptop, notebook', 'computer']]); + } + + public function testRejectsExplicitMappingArrowInSynonymTerm(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must not contain "," or "=>"'); + + new SynonymConfiguration('en', [['laptop => notebook', 'computer']]); + } + + public function testFromApiResponseParsesSolrStringsIntoGroups(): void + { + $config = SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + 'synonyms' => [ + 'laptop, notebook,computer', + 'phone, mobile', + ], + ]); + + $this->assertEquals('en', $config->language); + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile'], + ], + $config->synonyms + ); + } + public function testToArrayReturnsJsonSerializeOutput(): void { $config = new SynonymConfiguration('en', [['laptop', 'notebook']]); @@ -242,7 +281,13 @@ public function testJsonEncodeProducesValidJson(): void $decoded = json_decode($json, true); $this->assertEquals('en', $decoded['language']); - $this->assertEquals($synonyms, $decoded['synonyms']); + $this->assertEquals( + [ + 'laptop, notebook, computer', + 'phone, mobile, smartphone', + ], + $decoded['synonyms'] + ); } public function testChainedWithMethods(): void @@ -313,9 +358,9 @@ public function testMatchesOpenApiEcommerceEnExample(): void $expected = [ 'language' => 'en', 'synonyms' => [ - ['laptop', 'notebook', 'computer'], - ['phone', 'mobile', 'smartphone'], - ['shoes', 'footwear', 'sneakers'], + 'laptop, notebook, computer', + 'phone, mobile, smartphone', + 'shoes, footwear, sneakers', ], ]; diff --git a/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json b/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json index 5747d07..2b17889 100644 --- a/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json +++ b/tests/fixtures/openapi-examples/synonyms-ecommerce-en.json @@ -1,8 +1,8 @@ { "language": "en", "synonyms": [ - ["laptop", "notebook", "computer"], - ["phone", "mobile", "smartphone"], - ["shoes", "footwear", "sneakers"] + "laptop, notebook, computer", + "phone, mobile, smartphone", + "shoes, footwear, sneakers" ] } From c5dee3fe30ab8f297229323cd97a8afd87ffeff3 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 16:17:38 +0300 Subject: [PATCH 02/10] Address PR review feedback on synonym configuration --- src/SyncV2Sdk.php | 20 ++- .../ValueObjects/Response/SynonymResponse.php | 12 +- .../Synonym/NormalizesSynonymGroups.php | 29 ++++ .../Synonym/SynonymConfiguration.php | 163 +++++++++++------- .../Response/SynonymResponseTest.php | 32 ++-- .../Synonym/SynonymConfigurationTest.php | 21 ++- 6 files changed, 186 insertions(+), 91 deletions(-) create mode 100644 src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index d4e7dac..73d1903 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -216,15 +216,29 @@ public function getSynonyms(string $language): SynonymResponse $this->baseApiPath . 'synonyms?language=' . urlencode($language) ); - // GET returns {language, synonyms} without count/reindex fields. + return SynonymResponse::fromArray( + $this->withSynonymGetDefaults($response, $language) + ); + } + + /** + * The GET endpoint may omit synonym_count/requires_reindex; default them so + * SynonymResponse::fromArray() always receives its required fields. + * + * @param array $response + * @return array + */ + private function withSynonymGetDefaults(array $response, string $language): array + { $synonyms = $response['synonyms'] ?? []; + $synonyms = is_array($synonyms) ? $synonyms : []; - return SynonymResponse::fromArray([ + return [ 'language' => $response['language'] ?? $language, 'synonym_count' => $response['synonym_count'] ?? count($synonyms), 'requires_reindex' => $response['requires_reindex'] ?? false, 'synonyms' => $synonyms, - ]); + ]; } /** diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index 1e1ee0b..d6bb231 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -5,6 +5,7 @@ namespace BradSearch\SyncSdk\V2\ValueObjects\Response; use BradSearch\SyncSdk\V2\Exceptions\InvalidArgumentException; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\NormalizesSynonymGroups; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; /** @@ -18,6 +19,8 @@ */ final readonly class SynonymResponse extends ValueObject { + use NormalizesSynonymGroups; + private const LANGUAGE_PATTERN = '/^[a-z]{2}$/'; /** @@ -65,7 +68,7 @@ public static function fromArray(array $data): self * The API returns each synonym group as a Solr-format string * ("laptop, notebook"); normalize those into term arrays. * - * @param array>|null $synonyms + * @param array|null $synonyms * @return array>|null */ private static function normalizeSynonyms(?array $synonyms): ?array @@ -74,12 +77,7 @@ private static function normalizeSynonyms(?array $synonyms): ?array return null; } - return array_map( - static fn(string|array $group): array => is_string($group) - ? array_map('trim', explode(',', $group)) - : $group, - $synonyms - ); + return array_map(self::normalizeGroup(...), $synonyms); } /** diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php new file mode 100644 index 0000000..06c6347 --- /dev/null +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -0,0 +1,29 @@ + + */ + private static function normalizeGroup(mixed $group): array + { + if (is_string($group)) { + return array_map('trim', explode(',', $group)); + } + + return is_array($group) ? array_values($group) : []; + } +} diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index 488e57d..eff2b7a 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -15,6 +15,8 @@ */ final readonly class SynonymConfiguration extends ValueObject { + use NormalizesSynonymGroups; + private const LANGUAGE_PATTERN = '/^[a-z]{2}$/'; /** @@ -61,14 +63,22 @@ public function addSynonym(array $synonymGroup): self * Creates a SynonymConfiguration from an API response payload, where each * synonym group is a Solr-format string (e.g. "laptop, notebook"). * + * Returns null when the response carries no synonyms — an empty list is a + * valid API state, whereas constructing a config to send requires at least + * one group. + * * @param array $data */ - public static function fromApiResponse(array $data): self + public static function fromApiResponse(array $data): ?self { - $synonyms = array_map( - static fn(string $group): array => array_map('trim', explode(',', $group)), - $data['synonyms'] ?? [] - ); + $rawSynonyms = $data['synonyms'] ?? []; + $rawSynonyms = is_array($rawSynonyms) ? $rawSynonyms : []; + + if (empty($rawSynonyms)) { + return null; + } + + $synonyms = array_map(self::normalizeGroup(...), $rawSynonyms); return new self((string) ($data['language'] ?? ''), $synonyms); } @@ -127,65 +137,90 @@ private function validateSynonyms(array $synonyms): void } foreach ($synonyms as $index => $synonymGroup) { - if (!is_array($synonymGroup)) { - throw new InvalidArgumentException( - sprintf( - 'Synonym group at index %d must be an array, got %s.', - $index, - gettype($synonymGroup) - ), - 'synonyms', - $synonyms - ); - } - - if (empty($synonymGroup)) { - throw new InvalidArgumentException( - sprintf('Synonym group at index %d cannot be empty.', $index), - 'synonyms', - $synonyms - ); - } - - foreach ($synonymGroup as $termIndex => $term) { - if (!is_string($term)) { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] must be a string, got %s.', - $index, - $termIndex, - gettype($term) - ), - 'synonyms', - $synonyms - ); - } - - if (trim($term) === '') { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] cannot be empty.', - $index, - $termIndex - ), - 'synonyms', - $synonyms - ); - } - - if (str_contains($term, ',') || str_contains($term, '=>')) { - throw new InvalidArgumentException( - sprintf( - 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', - $index, - $termIndex, - $term - ), - 'synonyms', - $synonyms - ); - } - } + $this->validateSynonymGroup($index, $synonymGroup, $synonyms); + } + } + + /** + * Validates a single synonym group: it must be a non-empty array of terms. + * + * @param array> $synonyms Full set, for error context + * + * @throws InvalidArgumentException If the group is not a non-empty array + */ + private function validateSynonymGroup(int $index, mixed $synonymGroup, array $synonyms): void + { + if (!is_array($synonymGroup)) { + throw new InvalidArgumentException( + sprintf( + 'Synonym group at index %d must be an array, got %s.', + $index, + gettype($synonymGroup) + ), + 'synonyms', + $synonyms + ); + } + + if (empty($synonymGroup)) { + throw new InvalidArgumentException( + sprintf('Synonym group at index %d cannot be empty.', $index), + 'synonyms', + $synonyms + ); + } + + foreach ($synonymGroup as $termIndex => $term) { + $this->validateSynonymTerm($index, $termIndex, $term, $synonyms); + } + } + + /** + * Validates a single synonym term: a non-empty string free of Solr syntax + * characters ("," and "=>"). + * + * @param array> $synonyms Full set, for error context + * + * @throws InvalidArgumentException If the term is invalid + */ + private function validateSynonymTerm(int $index, int $termIndex, mixed $term, array $synonyms): void + { + if (!is_string($term)) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must be a string, got %s.', + $index, + $termIndex, + gettype($term) + ), + 'synonyms', + $synonyms + ); + } + + if (trim($term) === '') { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] cannot be empty.', + $index, + $termIndex + ), + 'synonyms', + $synonyms + ); + } + + if (str_contains($term, ',') || str_contains($term, '=>')) { + throw new InvalidArgumentException( + sprintf( + 'Synonym term at index [%d][%d] must not contain "," or "=>" (Solr syntax characters), got "%s".', + $index, + $termIndex, + $term + ), + 'synonyms', + $synonyms + ); } } } diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index 6c9c90d..33ef373 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -244,23 +244,29 @@ public function testToArrayReturnsJsonSerializeOutput(): void */ public function testMatchesOpenApiExampleResponse(): void { - $apiResponse = [ - 'language' => 'en', - 'synonym_count' => 3, - 'requires_reindex' => true, - 'synonyms' => [ - ['laptop', 'notebook', 'computer'], - ['phone', 'mobile', 'smartphone'], - ['shoes', 'footwear', 'sneakers'], - ], - ]; + $fixture = json_decode( + (string) file_get_contents( + __DIR__ . '/../../../fixtures/openapi-examples/synonyms-ecommerce-en.json' + ), + true + ); - $response = SynonymResponse::fromArray($apiResponse); + // The fixture is the Solr-string GET shape and omits count/reindex. + $response = SynonymResponse::fromArray($fixture + [ + 'synonym_count' => count($fixture['synonyms']), + 'requires_reindex' => false, + ]); $this->assertEquals('en', $response->language); $this->assertEquals(3, $response->synonymCount); - $this->assertTrue($response->requiresReindex); - $this->assertCount(3, $response->synonyms); + $this->assertEquals( + [ + ['laptop', 'notebook', 'computer'], + ['phone', 'mobile', 'smartphone'], + ['shoes', 'footwear', 'sneakers'], + ], + $response->synonyms + ); } public function testJsonEncodeProducesValidJson(): void diff --git a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php index be76ac8..1f1bf27 100644 --- a/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php +++ b/tests/V2/ValueObjects/Synonym/SynonymConfigurationTest.php @@ -184,6 +184,7 @@ public function testFromApiResponseParsesSolrStringsIntoGroups(): void ], ]); + $this->assertNotNull($config); $this->assertEquals('en', $config->language); $this->assertEquals( [ @@ -194,6 +195,18 @@ public function testFromApiResponseParsesSolrStringsIntoGroups(): void ); } + public function testFromApiResponseReturnsNullForEmptySynonyms(): void + { + $this->assertNull(SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + 'synonyms' => [], + ])); + + $this->assertNull(SynonymConfiguration::fromApiResponse([ + 'language' => 'en', + ])); + } + public function testToArrayReturnsJsonSerializeOutput(): void { $config = new SynonymConfiguration('en', [['laptop', 'notebook']]); @@ -398,10 +411,10 @@ public function testAcceptsLargeSynonymGroup(): void public function testAcceptsManySynonymGroups(): void { - $synonyms = []; - for ($i = 0; $i < 100; $i++) { - $synonyms[] = ["term{$i}a", "term{$i}b"]; - } + $synonyms = array_map( + fn(int $i): array => ["term{$i}a", "term{$i}b"], + range(0, 99) + ); $config = new SynonymConfiguration('en', $synonyms); From 03f6957e3c8d36d5fa1b21bbb810158c7ebc367f Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 16:26:26 +0300 Subject: [PATCH 03/10] Harden SynonymResponse against malformed synonyms payloads - normalizeSynonyms accepts mixed and returns null for non-array input, avoiding a TypeError on the public fromArray() path - Drop malformed groups that normalize to empty so the result never carries an untraceable [] - Load the OpenAPI fixture via array_merge so fixture values stay authoritative over the supplied defaults Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValueObjects/Response/SynonymResponse.php | 16 ++++++--- .../Response/SynonymResponseTest.php | 34 +++++++++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/V2/ValueObjects/Response/SynonymResponse.php b/src/V2/ValueObjects/Response/SynonymResponse.php index d6bb231..0d53324 100644 --- a/src/V2/ValueObjects/Response/SynonymResponse.php +++ b/src/V2/ValueObjects/Response/SynonymResponse.php @@ -68,16 +68,24 @@ public static function fromArray(array $data): self * The API returns each synonym group as a Solr-format string * ("laptop, notebook"); normalize those into term arrays. * - * @param array|null $synonyms + * Non-array payloads (null, or the whole field arriving as a single + * string) yield null rather than a TypeError, since fromArray() is public. + * Malformed entries that normalize to an empty group are dropped so the + * result never carries an untraceable []. + * + * @param mixed $synonyms * @return array>|null */ - private static function normalizeSynonyms(?array $synonyms): ?array + private static function normalizeSynonyms(mixed $synonyms): ?array { - if ($synonyms === null) { + if (!is_array($synonyms)) { return null; } - return array_map(self::normalizeGroup(...), $synonyms); + return array_values(array_filter( + array_map(self::normalizeGroup(...), $synonyms), + static fn(array $group): bool => !empty($group) + )); } /** diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index 33ef373..07e2be3 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -109,6 +109,33 @@ public function testFromArrayNormalizesSolrStringSynonymsIntoGroups(): void ); } + public function testFromArrayHandlesNonArraySynonymsWithoutTypeError(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 0, + 'requires_reindex' => false, + 'synonyms' => 'laptop, notebook', + ]); + + $this->assertNull($response->synonyms); + } + + public function testFromArrayDropsMalformedGroupsThatNormalizeToEmpty(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, notebook', + 123, + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); @@ -251,11 +278,12 @@ public function testMatchesOpenApiExampleResponse(): void true ); - // The fixture is the Solr-string GET shape and omits count/reindex. - $response = SynonymResponse::fromArray($fixture + [ + // The fixture is the Solr-string GET shape and omits count/reindex; + // supply defaults that the fixture overrides if it ever gains them. + $response = SynonymResponse::fromArray(array_merge([ 'synonym_count' => count($fixture['synonyms']), 'requires_reindex' => false, - ]); + ], $fixture)); $this->assertEquals('en', $response->language); $this->assertEquals(3, $response->synonymCount); From 5332eb9d4d33e6fb354860c952350bfb05d4da6a Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 16:55:38 +0300 Subject: [PATCH 04/10] Trim array-form synonym groups and document parsing divergence - normalizeGroup now trims string elements and discards non-string elements in the array branch, matching the Solr-string branch - Document that fromApiResponse throws on malformed entries whereas SynonymResponse::normalizeSynonyms drops them silently - Note that getSynonyms always returns a non-null synonyms array Co-Authored-By: Claude Opus 4.8 (1M context) --- src/SyncV2Sdk.php | 4 ++++ .../Synonym/NormalizesSynonymGroups.php | 14 +++++++++++--- .../ValueObjects/Synonym/SynonymConfiguration.php | 6 ++++++ .../ValueObjects/Response/SynonymResponseTest.php | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/SyncV2Sdk.php b/src/SyncV2Sdk.php index 73d1903..b359b41 100644 --- a/src/SyncV2Sdk.php +++ b/src/SyncV2Sdk.php @@ -207,6 +207,10 @@ public function setSynonyms(SynonymConfiguration $config): SynonymResponse /** * Get search synonyms for a specific language. * + * The returned response always carries a non-null synonyms array (empty + * when there are none), unlike SynonymResponse::fromArray() called directly + * without a synonyms key, which yields null. + * * @param string $language Language code (e.g., "en", "lt") * @return SynonymResponse Typed response with synonyms data */ diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php index 06c6347..b8135b3 100644 --- a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -13,8 +13,9 @@ trait NormalizesSynonymGroups { /** - * Normalizes a single synonym group: Solr-format strings are split on - * commas, arrays pass through, anything else collapses to an empty group. + * Normalizes a single synonym group into trimmed string terms: Solr-format + * strings are split on commas; arrays have their string elements trimmed and + * non-string elements discarded; anything else collapses to an empty group. * * @return array */ @@ -24,6 +25,13 @@ private static function normalizeGroup(mixed $group): array return array_map('trim', explode(',', $group)); } - return is_array($group) ? array_values($group) : []; + if (!is_array($group)) { + return []; + } + + return array_values(array_filter( + array_map(static fn(mixed $term): string => is_string($term) ? trim($term) : '', $group), + static fn(string $term): bool => $term !== '' + )); } } diff --git a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php index eff2b7a..23fe1a2 100644 --- a/src/V2/ValueObjects/Synonym/SynonymConfiguration.php +++ b/src/V2/ValueObjects/Synonym/SynonymConfiguration.php @@ -68,6 +68,12 @@ public function addSynonym(array $synonymGroup): self * one group. * * @param array $data + * + * @throws InvalidArgumentException If a synonym group cannot be parsed + * (e.g. a non-string/non-array entry that + * normalizes to an empty group), unlike + * SynonymResponse::normalizeSynonyms() + * which drops such groups silently. */ public static function fromApiResponse(array $data): ?self { diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index 07e2be3..ff0ea66 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -136,6 +136,20 @@ public function testFromArrayDropsMalformedGroupsThatNormalizeToEmpty(): void $this->assertEquals([['laptop', 'notebook']], $response->synonyms); } + public function testFromArrayTrimsArrayFormGroupsAndDropsNonStringElements(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + ['laptop ', ' notebook', 123], + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); From 163ed463bf8e155e826c81267bc5ea092d670335 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Mon, 22 Jun 2026 17:07:10 +0300 Subject: [PATCH 05/10] Filter empty terms consistently across both normalizeGroup branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Solr-string branch now drops empty/whitespace-only terms via a shared trimTerms() helper, matching the array branch — so a malformed input like "a,,b" yields ['a', 'b'] regardless of source format. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Synonym/NormalizesSynonymGroups.php | 18 ++++++++++++++++-- .../Response/SynonymResponseTest.php | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php index b8135b3..80fd938 100644 --- a/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php +++ b/src/V2/ValueObjects/Synonym/NormalizesSynonymGroups.php @@ -22,15 +22,29 @@ trait NormalizesSynonymGroups private static function normalizeGroup(mixed $group): array { if (is_string($group)) { - return array_map('trim', explode(',', $group)); + return self::trimTerms(explode(',', $group)); } if (!is_array($group)) { return []; } + return self::trimTerms(array_map( + static fn(mixed $term): string => is_string($term) ? $term : '', + $group + )); + } + + /** + * Trims terms and discards any that are empty after trimming. + * + * @param array $terms + * @return array + */ + private static function trimTerms(array $terms): array + { return array_values(array_filter( - array_map(static fn(mixed $term): string => is_string($term) ? trim($term) : '', $group), + array_map('trim', $terms), static fn(string $term): bool => $term !== '' )); } diff --git a/tests/V2/ValueObjects/Response/SynonymResponseTest.php b/tests/V2/ValueObjects/Response/SynonymResponseTest.php index ff0ea66..d9fdaac 100644 --- a/tests/V2/ValueObjects/Response/SynonymResponseTest.php +++ b/tests/V2/ValueObjects/Response/SynonymResponseTest.php @@ -150,6 +150,20 @@ public function testFromArrayTrimsArrayFormGroupsAndDropsNonStringElements(): vo $this->assertEquals([['laptop', 'notebook']], $response->synonyms); } + public function testFromArrayDropsEmptyTermsFromSolrStringGroups(): void + { + $response = SynonymResponse::fromArray([ + 'language' => 'en', + 'synonym_count' => 1, + 'requires_reindex' => false, + 'synonyms' => [ + 'laptop, , notebook', + ], + ]); + + $this->assertEquals([['laptop', 'notebook']], $response->synonyms); + } + public function testFromArrayThrowsOnMissingLanguage(): void { $this->expectException(InvalidArgumentException::class); From 63bf39d0208f82be383a5af55aa5d81b9c1dad55 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Thu, 25 Jun 2026 12:07:30 +0300 Subject: [PATCH 06/10] Allow synonyms to be sent at index creation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IndexCreateRequest now accepts an optional per-language SynonymConfiguration list, serialized into the create-index payload (under a "synonyms" key, omitted when empty). This lets a full reindex/new-index job apply synonyms up front so they take effect on activation, avoiding the close -> update settings -> open cycle of a post-activation setSynonyms() call that briefly interrupts storefront search. createIndex() is unchanged — it already serializes the request body. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValueObjects/Index/IndexCreateRequest.php | 68 +++++++++++++++++-- .../Index/IndexCreateRequestTest.php | 59 ++++++++++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/V2/ValueObjects/Index/IndexCreateRequest.php b/src/V2/ValueObjects/Index/IndexCreateRequest.php index 9ad2eb9..d2b5a47 100644 --- a/src/V2/ValueObjects/Index/IndexCreateRequest.php +++ b/src/V2/ValueObjects/Index/IndexCreateRequest.php @@ -6,6 +6,7 @@ use BradSearch\SyncSdk\V2\Exceptions\InvalidArgumentException; use BradSearch\SyncSdk\V2\Exceptions\InvalidLocaleException; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\SynonymConfiguration; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; /** @@ -14,6 +15,9 @@ * This immutable ValueObject contains the required data for creating a new index: * - locales: Array of locale codes in 'xx-XX' format * - fields: Array of FieldDefinition objects + * - synonyms: Optional per-language synonym configurations, applied at index + * creation so they take effect on activation without a separate post-activation + * update (which would briefly close the active index). */ final readonly class IndexCreateRequest extends ValueObject { @@ -25,13 +29,16 @@ /** * @param array $locales Array of locale codes (e.g., ['lt-LT', 'en-US']) * @param array $fields Array of field definitions + * @param array $synonyms Optional per-language synonym configurations */ public function __construct( array $locales, - public array $fields + public array $fields, + public array $synonyms = [] ) { $this->validateLocales($locales); $this->validateFields($fields); + $this->validateSynonyms($synonyms); $this->locales = $locales; } @@ -42,7 +49,7 @@ public function __construct( */ public function withLocales(array $locales): self { - return new self($locales, $this->fields); + return new self($locales, $this->fields, $this->synonyms); } /** @@ -52,7 +59,17 @@ public function withLocales(array $locales): self */ public function withFields(array $fields): self { - return new self($this->locales, $fields); + return new self($this->locales, $fields, $this->synonyms); + } + + /** + * Returns a new instance with different synonym configurations. + * + * @param array $synonyms + */ + public function withSynonyms(array $synonyms): self + { + return new self($this->locales, $this->fields, $synonyms); } /** @@ -60,7 +77,7 @@ public function withFields(array $fields): self */ public function withAddedLocale(string $locale): self { - return new self([...$this->locales, $locale], $this->fields); + return new self([...$this->locales, $locale], $this->fields, $this->synonyms); } /** @@ -68,7 +85,15 @@ public function withAddedLocale(string $locale): self */ public function withAddedField(FieldDefinition $field): self { - return new self($this->locales, [...$this->fields, $field]); + return new self($this->locales, [...$this->fields, $field], $this->synonyms); + } + + /** + * Returns a new instance with an additional synonym configuration. + */ + public function withAddedSynonym(SynonymConfiguration $synonym): self + { + return new self($this->locales, $this->fields, [...$this->synonyms, $synonym]); } /** @@ -76,13 +101,22 @@ public function withAddedField(FieldDefinition $field): self */ public function jsonSerialize(): array { - return [ + $data = [ 'locales' => $this->locales, 'fields' => array_map( fn(FieldDefinition $field) => $field->jsonSerialize(), $this->fields ), ]; + + if ($this->synonyms !== []) { + $data['synonyms'] = array_map( + fn(SynonymConfiguration $synonym) => $synonym->jsonSerialize(), + $this->synonyms + ); + } + + return $data; } /** @@ -143,4 +177,26 @@ private function validateFields(array $fields): void } } } + + /** + * Validates that all synonym entries are SynonymConfiguration instances. + * + * @param array $synonyms + * @throws InvalidArgumentException If an entry is not a SynonymConfiguration + */ + private function validateSynonyms(array $synonyms): void + { + foreach ($synonyms as $index => $synonym) { + if (!$synonym instanceof SynonymConfiguration) { + throw new InvalidArgumentException( + sprintf( + 'Synonym at index %d must be an instance of SynonymConfiguration.', + $index + ), + 'synonyms', + $synonym + ); + } + } + } } diff --git a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php index abf9648..b091d82 100644 --- a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php +++ b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php @@ -10,6 +10,7 @@ use BradSearch\SyncSdk\V2\ValueObjects\Index\FieldType; use BradSearch\SyncSdk\V2\ValueObjects\Index\IndexCreateRequest; use BradSearch\SyncSdk\V2\ValueObjects\Index\VariantAttribute; +use BradSearch\SyncSdk\V2\ValueObjects\Synonym\SynonymConfiguration; use BradSearch\SyncSdk\V2\ValueObjects\ValueObject; use JsonSerializable; use PHPUnit\Framework\TestCase; @@ -463,4 +464,62 @@ public function testInvalidFieldInMiddleOfArray(): void ] ); } + + public function testSynonymsDefaultToEmptyAndAreOmittedFromSerialization(): void + { + $request = new IndexCreateRequest( + ['lt-LT'], + [new FieldDefinition('id', FieldType::KEYWORD)] + ); + + $this->assertEquals([], $request->synonyms); + $this->assertArrayNotHasKey('synonyms', $request->jsonSerialize()); + } + + public function testSynonymsAreSerializedAsSolrStringsWhenProvided(): void + { + $request = new IndexCreateRequest( + ['en-US', 'lt-LT'], + [new FieldDefinition('name', FieldType::TEXT)], + [ + new SynonymConfiguration('en', [['laptop', 'notebook']]), + new SynonymConfiguration('lt', [['telefonas', 'mobilusis']]), + ] + ); + + $this->assertEquals( + [ + ['language' => 'en', 'synonyms' => ['laptop, notebook']], + ['language' => 'lt', 'synonyms' => ['telefonas, mobilusis']], + ], + $request->jsonSerialize()['synonyms'] + ); + } + + public function testWithSynonymsReturnsNewInstance(): void + { + $request = new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)] + ); + + $synonyms = [new SynonymConfiguration('en', [['laptop', 'notebook']])]; + $updated = $request->withSynonyms($synonyms); + + $this->assertNotSame($request, $updated); + $this->assertEquals([], $request->synonyms); + $this->assertEquals($synonyms, $updated->synonyms); + } + + public function testThrowsExceptionForInvalidSynonymEntry(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Synonym at index 0 must be an instance of SynonymConfiguration.'); + + new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + [['language' => 'en', 'synonyms' => [['laptop', 'notebook']]]] + ); + } } From 5cfbcf06f63ddc26a30e47ec3f87f5b0fb7bc756 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Thu, 25 Jun 2026 18:13:57 +0300 Subject: [PATCH 07/10] address reviewer comments --- .../ValueObjects/Index/IndexCreateRequest.php | 20 +++++- .../Index/IndexCreateRequestTest.php | 65 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/V2/ValueObjects/Index/IndexCreateRequest.php b/src/V2/ValueObjects/Index/IndexCreateRequest.php index d2b5a47..fb507d5 100644 --- a/src/V2/ValueObjects/Index/IndexCreateRequest.php +++ b/src/V2/ValueObjects/Index/IndexCreateRequest.php @@ -179,13 +179,17 @@ private function validateFields(array $fields): void } /** - * Validates that all synonym entries are SynonymConfiguration instances. + * Validates that all synonym entries are SynonymConfiguration instances and + * that each language appears at most once. * * @param array $synonyms * @throws InvalidArgumentException If an entry is not a SynonymConfiguration + * or a language is configured more than once */ private function validateSynonyms(array $synonyms): void { + $seenLanguages = []; + foreach ($synonyms as $index => $synonym) { if (!$synonym instanceof SynonymConfiguration) { throw new InvalidArgumentException( @@ -197,6 +201,20 @@ private function validateSynonyms(array $synonyms): void $synonym ); } + + if (isset($seenLanguages[$synonym->language])) { + throw new InvalidArgumentException( + sprintf( + 'Duplicate synonym configuration for language "%s" at index %d; each language must appear at most once.', + $synonym->language, + $index + ), + 'synonyms', + $synonyms + ); + } + + $seenLanguages[$synonym->language] = true; } } } diff --git a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php index b091d82..2e4a139 100644 --- a/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php +++ b/tests/V2/ValueObjects/Index/IndexCreateRequestTest.php @@ -511,6 +511,25 @@ public function testWithSynonymsReturnsNewInstance(): void $this->assertEquals($synonyms, $updated->synonyms); } + public function testWithAddedSynonymReturnsNewInstance(): void + { + $originalSynonym = new SynonymConfiguration('en', [['laptop', 'notebook']]); + $newSynonym = new SynonymConfiguration('lt', [['telefonas', 'mobilusis']]); + + $request = new IndexCreateRequest( + ['en-US', 'lt-LT'], + [new FieldDefinition('name', FieldType::TEXT)], + [$originalSynonym] + ); + $updated = $request->withAddedSynonym($newSynonym); + + $this->assertNotSame($request, $updated); + $this->assertCount(1, $request->synonyms); + $this->assertCount(2, $updated->synonyms); + $this->assertSame($originalSynonym, $updated->synonyms[0]); + $this->assertSame($newSynonym, $updated->synonyms[1]); + } + public function testThrowsExceptionForInvalidSynonymEntry(): void { $this->expectException(InvalidArgumentException::class); @@ -522,4 +541,50 @@ public function testThrowsExceptionForInvalidSynonymEntry(): void [['language' => 'en', 'synonyms' => [['laptop', 'notebook']]]] ); } + + /** + * @dataProvider builderProvider + * @param callable(IndexCreateRequest): IndexCreateRequest $builder + */ + public function testBuildersPreserveSynonyms(callable $builder): void + { + $synonyms = [new SynonymConfiguration('en', [['laptop', 'notebook']])]; + $request = new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + $synonyms + ); + + $result = $builder($request); + + $this->assertEquals($synonyms, $result->synonyms); + } + + /** + * @return array + */ + public static function builderProvider(): array + { + return [ + 'withLocales' => [fn(IndexCreateRequest $r) => $r->withLocales(['lt-LT'])], + 'withFields' => [fn(IndexCreateRequest $r) => $r->withFields([new FieldDefinition('title', FieldType::TEXT)])], + 'withAddedLocale' => [fn(IndexCreateRequest $r) => $r->withAddedLocale('lt-LT')], + 'withAddedField' => [fn(IndexCreateRequest $r) => $r->withAddedField(new FieldDefinition('title', FieldType::TEXT))], + ]; + } + + public function testThrowsExceptionForDuplicateSynonymLanguage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Duplicate synonym configuration for language "en" at index 1'); + + new IndexCreateRequest( + ['en-US'], + [new FieldDefinition('name', FieldType::TEXT)], + [ + new SynonymConfiguration('en', [['laptop', 'notebook']]), + new SynonymConfiguration('en', [['phone', 'mobile']]), + ] + ); + } } From 824329b20f0b02b66bb438d0cc400d8e17820076 Mon Sep 17 00:00:00 2001 From: Paulius Stuksys Date: Fri, 26 Jun 2026 11:41:20 +0300 Subject: [PATCH 08/10] release tag 2026.02 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a579926..58c11c3 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ foreach ($fields as $name => $config) { The SDK includes comprehensive validation and error handling. For testing: -1. Use the validation methods to check data before syncing +1. Use the validation methods to check data before syncing. 2. Start with small batches to verify configuration 3. Monitor API responses for any issues From b3d4f857beb136eab9ec17a1187ba90d969f71a1 Mon Sep 17 00:00:00 2001 From: Steponas Kaminskas <132608893+SteponasK@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:50:15 +0300 Subject: [PATCH 09/10] BRD-1127: Rewrite CLAUDE.md around V2, fixture parity, locale contract, quality gates --- CLAUDE.md | 198 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 87 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 420973b..aa1432e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,103 +1,127 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code when working with code in this repository (`bradsearch/search-sync-sdk`). Facts below verified against the working tree as of 2026-07-03. -## Development Commands +## What this is + +Pure-PHP (>= 8.4), zero runtime composer deps, client library for the Brad Search synchronization/admin HTTP API. Every payload it emits is an API contract: the server's OpenAPI v2 spec defines the shape, this repo's fixtures assert it byte-for-byte, and downstream consumers depend on it. + +## V1 / V2 — two generations coexist + +**V2 is the current, active surface. Center new work here.** V1 is legacy and stays alive only while customers remain on it — neither generation may be deleted. + +| | V1 (legacy) | V2 (current) | +|---|---|---| +| Facade | `src/SynchronizationApiSdk.php` | `src/SyncV2Sdk.php` | +| Config | `src/Config/SyncConfig.php` | `src/Config/SyncConfigV2.php` (appId **must be a UUID**; apiUrl, token, optional `targetIndex`) | +| Endpoints | `/api/v1/sync/...` | `/api/v2/applications/{appId}/...` | +| Payload style | raw arrays + `src/Validators/DataValidator.php` | **strict immutable readonly ValueObjects** in `src/V2/ValueObjects/` (BulkOperations, Index, Normalize, Product, Response, Search, SearchSettings, Synonym, Common) with constructor validation, builders, and `jsonSerialize()` asserted against fixtures | +| Adapters | `PrestaShopAdapter`, `MagentoAdapter` | `PrestaShopAdapterV2`, `MagentoAdapterV2`, `ShopifyAdapter` (V2-only) | + +Also: `src/AdminSdk.php` + `src/Client/AdminHttpClient.php` for `/api/v2/admin/indices` (raw physical index list/delete). + +**A field/feature that both generations expose must land twice** — once in the V1 array path, once in the V2 ValueObject path. Confirm with the owner before duplicating; some features (synonyms, search settings, alias versioning) are V2-only. Which generation a given customer uses is decided server-side by the consuming application, not here. + +The V2 design contract lives in `tasks/prd-v2-valueobjects.md` — read it before changing VO conventions (immutability, `with*()` methods, builders, exact OpenAPI alignment). + +## OpenAPI golden-fixture parity (the centerpiece discipline) + +`tests/fixtures/openapi-examples/*.json` are not sample data — they ARE the cross-repo contract test: + +``` +tests/fixtures/openapi-examples/ +├── index-create-darbo-drabuziai.json +├── bulk-operations-darbo-drabuziai.json +├── configuration-advanced.json +├── search-configuration-request.json +├── search-settings-full.json +└── synonyms-ecommerce-en.json +``` + +Each file is copied verbatim from an example payload in the server's OpenAPI v2 spec. `tests/V2/ApiPayloadVerificationTest.php` builds the same payload through the V2 ValueObjects/builders and asserts `jsonSerialize()` equals the decoded fixture — exact byte-level/structural alignment, not "close enough." `tests/V2/DarboDrabuziaiWorkflowTest.php` chains the fixtures into a full end-to-end simulation (create index v1 → configure → bulk-sync → create index v2 → sync → activate v2 → verify → cleanup, plus a rollback scenario). + +**If you change a V2 payload shape**, do this in lockstep, in one PR (after the server-side API change lands first): +1. Confirm the field/shape exists in the server's OpenAPI v2 spec first — never invent a shape SDK-side. +2. Update the ValueObject in `src/V2/ValueObjects//`. +3. Update the matching builder and `with*()` methods. +4. Update the affected fixture — copied from the server's OpenAPI spec, never hand-authored from memory. +5. Update `ApiPayloadVerificationTest` (and `DarboDrabuziaiWorkflowTest` if index-create/bulk-operations shape moved). +6. Decide explicitly whether the V1 side also needs the change. +7. Quality-gate triple green (below). + +**Failure smell**: if a fixture test fails, do not "fix" it by editing the fixture to match your output. The fixture mirrors the API spec. Either copy the spec's new example verbatim, or fix your VO — the fixture is never adjusted just to silence a test. + +## Locale-suffix contract + +Documented in `src/Adapters/README.md` ("Locale Handling"): + +1. The first locale in an adapter's constructor array is the default locale. +2. Default-locale fields are unsuffixed: `name`, `description`. +3. Every other locale gets a suffixed field: `name_lt-LT`, `description_en-US`. +4. Fallback: if a product is missing the default locale's value, adapters fall back to the first available locale. + +Enforced by `src/V2/ValueObjects/Common/LocalizedField.php`, which builds `_` and validates the locale against `^[a-z]{2}(-[A-Z]{2})?$` (region part is optional — `lt` is as valid as `lt-LT`), throwing `InvalidLocaleException` otherwise. + +Getting this wrong does not error — it silently breaks search relevance in one language (fields land under the wrong name; the engine's per-language analyzers never see them). Treat any locale-touching diff as high-risk; cover both the unsuffixed default and the suffixed path with tests. + +**Known debt**: V1's embeddable-fields builder hardcodes the locale pair — `src/SynchronizationApiSdk.php:348-349` (`$locales = ['en-US', 'lt-LT'];`). This blocks V1 customers outside that pair. It is a known gap; fixing it needs its own ticket (changes V1 index mappings) — do not fix it as a drive-by. + +## Development commands ### Testing ```bash -# Run all tests -vendor/bin/phpunit +vendor/bin/phpunit --testdox +``` +PHPUnit 11. `phpunit.xml` sets `failOnRisky` + `failOnWarning` — warnings fail the build. -# Run tests with coverage -vendor/bin/phpunit --coverage-html coverage +### Quality-gate triple — all three required, every PR -# Run a specific test -vendor/bin/phpunit tests/Adapters/PrestaShopAdapterTest.php -``` +This is exactly what CI runs (`.github/workflows/tests.yml`: a `tests` job and a `code-quality` job, PHP 8.4, extensions json/curl/bcmath): -### Code Quality ```bash -# Run PHPStan static analysis -vendor/bin/phpstan analyse +vendor/bin/phpunit --testdox # expect all green +vendor/bin/phpstan analyse # level 4, src/ only (phpstan.neon); expect "[OK] No errors" +vendor/bin/phpcs src tests # PSR-12 (phpcs.xml); expect empty output / exit 0 +``` -# Run PHP CodeSniffer -vendor/bin/phpcs src tests +`laravel/pint` is in require-dev but NOT wired into CI — phpcs is the authority. No Makefile, no docker-compose, no `.env`; tests are fully offline (HTTP is mocked). -# Install dependencies +### Install +```bash composer install - -# Update dependencies composer update ``` -## Code Architecture - -### Core SDK Structure -The PHP SDK for Brad Search synchronization is organized into a modular architecture: - -- **`SynchronizationApiSdk`** - Main SDK class providing the public API for index management and product synchronization -- **Field Configuration System** - Type-safe field definitions using PHP enums and configuration builders -- **Validation Layer** - Comprehensive data validation against field configurations before API calls -- **HTTP Client** - cURL-based client with error handling and authentication -- **Exception Hierarchy** - Typed exceptions for different error scenarios - -### Key Components - -#### SynchronizationApiSdk (src/SynchronizationApiSdk.php) -Main entry point providing methods: -- `createIndex()` / `deleteIndex()` - Index management -- `sync()` / `syncBulk()` - Product synchronization (single and batch) -- `copyIndex()` - Index replication -- `deleteProductsBulk()` - Bulk product deletion -- `validateProduct()` / `validateProducts()` - Data validation without syncing - -#### Field Configuration (src/Models/) -- **`FieldConfig`** - Individual field configuration with type and attributes -- **`FieldConfigBuilder`** - Helper for building common field configurations -- **`FieldType` enum** - Defines supported field types (TEXT_KEYWORD, HIERARCHY, VARIANTS, etc.) - -#### Validation System (src/Validators/) -- **`DataValidator`** - Validates product data against field configuration -- Supports all field types including hierarchical categories, variants with attributes, and URL validation -- Provides detailed error reporting - -#### HTTP Layer (src/Client/) -- **`HttpClient`** - Handles API communication with authentication, timeouts, and error handling -- Supports all HTTP methods (GET, POST, PUT, DELETE) with JSON encoding - -### API Endpoints -The SDK communicates with these endpoints: -- `DELETE /api/v1/sync/{index}` - Delete index -- `PUT /api/v1/sync/` - Create index with field configuration -- `POST /api/v1/sync/` - Bulk sync products -- `POST /api/v1/sync/reindex` - Copy/reindex operations -- `POST /api/v1/sync/delete-products` - Bulk delete products - -### Field Types Supported -- `TEXT_KEYWORD` - Full-text search with keyword matching -- `TEXT` - Full-text search only -- `KEYWORD` - Exact keyword matching -- `HIERARCHY` - Hierarchical categories (e.g., "Clothing > T-Shirts > Premium") -- `VARIANTS` - Product variants with configurable attributes -- `NAME_VALUE_LIST` - Key-value pairs (features, specifications) -- `IMAGE_URL` - Image URLs object with size keys -- `URL` - Regular URLs with validation -- `FLOAT`, `INTEGER`, `DOUBLE` - Numeric types - -### Data Processing -- **Field Filtering** - Only configured fields are sent to API -- **Batch Processing** - Large datasets automatically chunked (default 100 items per batch) -- **Validation First** - All products validated before any API calls -- **Embeddable Fields** - Support for localized fields with configurable locales - -### PrestaShop Integration -The SDK includes a PrestaShop adapter (`src/Adapters/PrestaShopAdapter.php`) for e-commerce platform integration, handling product data mapping and synchronization specific to PrestaShop's data structure. - -### Dependencies -- **PHP 8.4+** - Uses modern PHP features (readonly properties, enums, constructor property promotion) -- **ext-json** - JSON encoding/decoding -- **ext-curl** - HTTP client functionality -- **PHPUnit** - Testing framework -- **PHPStan** - Static analysis -- **PHP CodeSniffer** - Code style enforcement \ No newline at end of file +## Code architecture + +### Key components +- **`SynchronizationApiSdk`** (V1) / **`SyncV2Sdk`** (V2) — main facades. +- **Field Configuration** (`src/Models/`) — `FieldConfig`, `FieldConfigBuilder`, `FieldType` enum (V1). +- **ValueObjects** (`src/V2/ValueObjects/`) — V2 payload types with constructor validation and builders. +- **Validation** (`src/Validators/DataValidator.php`) — V1 client-side validation before any API call. +- **HTTP** (`src/Client/`) — `HttpClient` (V1), `AdminHttpClient` (admin API). +- **Adapters** (`src/Adapters/`) — `PrestaShopAdapter`/`PrestaShopAdapterV2`, `MagentoAdapter`/`MagentoAdapterV2` (GraphQL-fed via `src/Magento/`), `ShopifyAdapter` (V2-only). + +### targetIndex / alias semantics + +The API exposes versioned physical indices behind an alias named after the appId. `SyncConfigV2->targetIndex` defaults to `null`, so bulk ops normally hit the alias (the LIVE index). During a zero-downtime reindex, construct a second `SyncConfigV2` with `targetIndex` set to the new versioned index so bulk-loading targets the inactive version while search keeps serving the old one; only `activateIndexVersion()` flips traffic. If a sync appears to do nothing, check whether it wrote to a non-active version via `getIndexInfo()`. `AdminSdk` lists raw physical indices, not aliases. + +### Price correctness (recurring bug class) + +- **Shopify money math is bcmath-only, mandatorily**: `ShopifyAdapter` refuses to construct without `bccomp` and compares prices with `bccomp(..., 2)`. Never replace bcmath comparisons with float `>`/`==`. +- bcmath is Shopify-only — `PrestaShopAdapterV2`/`MagentoAdapterV2` use native numerics with explicit zero-price guards. This asymmetry is historical, not principled; keep zero-price guards intact if you touch non-Shopify price code. +- Zero/empty prices are legitimate inputs from every platform — treat as "no discount", never divide by them. + +### Who consumes this SDK + +- The primary consumer is a **Laravel application**, resolving `bradsearch/search-sync-sdk` from packagist.org (no `repositories` block). Local dev uses a composer path-repository symlink to your checkout. +- The **PrestaShop module** does NOT depend on this SDK — it targets PHP >= 7.1 and owns its own product transformation. +- Releases are git tags (`vMAJOR.MINOR.PATCH`); there is no publish workflow in this repo. + +## Dependencies + +- **PHP >= 8.4** — readonly properties, enums, constructor property promotion. +- **ext-json** — JSON encoding/decoding. +- **ext-curl** — HTTP client. +- **ext-bcmath** — required for Shopify decimal-safe price comparisons (`ShopifyAdapter`). +- **PHPUnit 11**, **PHPStan** (level 4), **PHP CodeSniffer** (PSR-12) — dev-only, see quality-gate triple above. From 51c32e6d612e9dfee6902f57e09387df03a02ff7 Mon Sep 17 00:00:00 2001 From: Steponas Kaminskas <132608893+SteponasK@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:35:24 +0300 Subject: [PATCH 10/10] BRD-1127: Rewrite CLAUDE.md around V2 (public-safe); add ValueObjects child doc --- CLAUDE.md | 79 ++++++++++++++--------------------- src/V2/ValueObjects/CLAUDE.md | 34 +++++++++++++++ 2 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 src/V2/ValueObjects/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index aa1432e..d664781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,32 +1,27 @@ # CLAUDE.md -This file provides guidance to Claude Code when working with code in this repository (`bradsearch/search-sync-sdk`). Facts below verified against the working tree as of 2026-07-03. +Guidance for Claude Code in this repo. This file covers what rarely changes; deep ValueObject conventions live in `src/V2/ValueObjects/CLAUDE.md` — read it before touching a payload type. ## What this is -Pure-PHP (>= 8.4), zero runtime composer deps, client library for the Brad Search synchronization/admin HTTP API. Every payload it emits is an API contract: the server's OpenAPI v2 spec defines the shape, this repo's fixtures assert it byte-for-byte, and downstream consumers depend on it. +`bradsearch/search-sync-sdk` is a pure-PHP (>= 8.4), zero-runtime-dependency client library for the Brad Search synchronization/admin HTTP API. It never talks to the search backend directly — only to that HTTP API. Every payload it emits is a contract: the server's OpenAPI spec defines the shape, this repo's fixtures assert it byte-for-byte, and every consumer (a Laravel application, plus Shopify/Magento sync jobs) depends on it not drifting. -## V1 / V2 — two generations coexist +**V1 is deprecated.** `src/SynchronizationApiSdk.php` and its array/`DataValidator` payload style are legacy — kept alive only for customers not yet migrated, never extended. All new work targets V2. -**V2 is the current, active surface. Center new work here.** V1 is legacy and stays alive only while customers remain on it — neither generation may be deleted. +## V2 architecture -| | V1 (legacy) | V2 (current) | -|---|---|---| -| Facade | `src/SynchronizationApiSdk.php` | `src/SyncV2Sdk.php` | -| Config | `src/Config/SyncConfig.php` | `src/Config/SyncConfigV2.php` (appId **must be a UUID**; apiUrl, token, optional `targetIndex`) | -| Endpoints | `/api/v1/sync/...` | `/api/v2/applications/{appId}/...` | -| Payload style | raw arrays + `src/Validators/DataValidator.php` | **strict immutable readonly ValueObjects** in `src/V2/ValueObjects/` (BulkOperations, Index, Normalize, Product, Response, Search, SearchSettings, Synonym, Common) with constructor validation, builders, and `jsonSerialize()` asserted against fixtures | -| Adapters | `PrestaShopAdapter`, `MagentoAdapter` | `PrestaShopAdapterV2`, `MagentoAdapterV2`, `ShopifyAdapter` (V2-only) | +- **Facade**: `src/SyncV2Sdk.php`. +- **Config**: `src/Config/SyncConfigV2.php` — `appId` (must be a UUID), `apiUrl`, `token`, optional `targetIndex`. +- **Endpoints**: `/api/v2/applications/{appId}/...`. +- **Payloads**: strict immutable readonly ValueObjects in `src/V2/ValueObjects/` (BulkOperations, Index, Normalize, Product, Response, Search, SearchSettings, Synonym, Common), each with constructor validation, a builder, and a `jsonSerialize()` verified against a fixture. See `src/V2/ValueObjects/CLAUDE.md` for the conventions. +- **Adapters**: `PrestaShopAdapterV2`, `MagentoAdapterV2` (GraphQL-fed via `src/Magento/`), `ShopifyAdapter` — transform platform product data into V2 payloads. +- **Admin**: `src/AdminSdk.php` + `src/Client/AdminHttpClient.php` for `/api/v2/admin/indices` (raw physical index list/delete). -Also: `src/AdminSdk.php` + `src/Client/AdminHttpClient.php` for `/api/v2/admin/indices` (raw physical index list/delete). - -**A field/feature that both generations expose must land twice** — once in the V1 array path, once in the V2 ValueObject path. Confirm with the owner before duplicating; some features (synonyms, search settings, alias versioning) are V2-only. Which generation a given customer uses is decided server-side by the consuming application, not here. - -The V2 design contract lives in `tasks/prd-v2-valueobjects.md` — read it before changing VO conventions (immutability, `with*()` methods, builders, exact OpenAPI alignment). +The V2 design contract lives in `tasks/prd-v2-valueobjects.md` — read it before changing ValueObject conventions (immutability, `with*()` methods, builders, exact API alignment). ## OpenAPI golden-fixture parity (the centerpiece discipline) -`tests/fixtures/openapi-examples/*.json` are not sample data — they ARE the cross-repo contract test: +`tests/fixtures/openapi-examples/*.json` are not sample data — they ARE the contract test: ``` tests/fixtures/openapi-examples/ @@ -38,18 +33,18 @@ tests/fixtures/openapi-examples/ └── synonyms-ecommerce-en.json ``` -Each file is copied verbatim from an example payload in the server's OpenAPI v2 spec. `tests/V2/ApiPayloadVerificationTest.php` builds the same payload through the V2 ValueObjects/builders and asserts `jsonSerialize()` equals the decoded fixture — exact byte-level/structural alignment, not "close enough." `tests/V2/DarboDrabuziaiWorkflowTest.php` chains the fixtures into a full end-to-end simulation (create index v1 → configure → bulk-sync → create index v2 → sync → activate v2 → verify → cleanup, plus a rollback scenario). +Each file is copied verbatim from an example payload in the server's OpenAPI spec. `tests/V2/ApiPayloadVerificationTest.php` builds the same payload through the V2 ValueObjects/builders and asserts `jsonSerialize()` equals the decoded fixture — exact structural alignment, not "close enough." `tests/V2/DarboDrabuziaiWorkflowTest.php` chains the fixtures into a full end-to-end simulation (create index → configure → bulk-sync → create new version → sync → activate → verify → cleanup, plus a rollback scenario). **If you change a V2 payload shape**, do this in lockstep, in one PR (after the server-side API change lands first): -1. Confirm the field/shape exists in the server's OpenAPI v2 spec first — never invent a shape SDK-side. +1. Confirm the field/shape exists in the server's OpenAPI spec first — never invent a shape SDK-side. 2. Update the ValueObject in `src/V2/ValueObjects//`. 3. Update the matching builder and `with*()` methods. 4. Update the affected fixture — copied from the server's OpenAPI spec, never hand-authored from memory. -5. Update `ApiPayloadVerificationTest` (and `DarboDrabuziaiWorkflowTest` if index-create/bulk-operations shape moved). -6. Decide explicitly whether the V1 side also needs the change. +5. Update `ApiPayloadVerificationTest` (and `DarboDrabuziaiWorkflowTest` if the index-create/bulk-operations shape moved). +6. Decide explicitly whether the deprecated V1 side also needs the change (it usually doesn't). 7. Quality-gate triple green (below). -**Failure smell**: if a fixture test fails, do not "fix" it by editing the fixture to match your output. The fixture mirrors the API spec. Either copy the spec's new example verbatim, or fix your VO — the fixture is never adjusted just to silence a test. +**Failure smell**: if a fixture test fails, do not "fix" it by editing the fixture to match your output. The fixture mirrors the API spec. Either copy the spec's new example verbatim, or fix your ValueObject — the fixture is never adjusted just to silence a test. ## Locale-suffix contract @@ -60,11 +55,19 @@ Documented in `src/Adapters/README.md` ("Locale Handling"): 3. Every other locale gets a suffixed field: `name_lt-LT`, `description_en-US`. 4. Fallback: if a product is missing the default locale's value, adapters fall back to the first available locale. -Enforced by `src/V2/ValueObjects/Common/LocalizedField.php`, which builds `_` and validates the locale against `^[a-z]{2}(-[A-Z]{2})?$` (region part is optional — `lt` is as valid as `lt-LT`), throwing `InvalidLocaleException` otherwise. +Enforced by `src/V2/ValueObjects/Common/LocalizedField.php`, which builds `_` and validates the locale against `^[a-z]{2}(-[A-Z]{2})?$` (the region part is optional — `lt` is as valid as `lt-LT`), throwing `InvalidLocaleException` otherwise. + +Getting this wrong does not error — it silently breaks search relevance in one language (fields land under the wrong name; the backend's per-language analysis never sees them). Treat any locale-touching diff as high-risk; cover both the unsuffixed default and the suffixed path with tests. + +## targetIndex / alias semantics + +The API exposes versioned physical indices behind an alias named after the appId. `SyncConfigV2->targetIndex` defaults to `null`, so bulk ops normally hit the alias (the LIVE index). During a zero-downtime reindex, construct a second `SyncConfigV2` with `targetIndex` set to the new versioned index so bulk-loading targets the inactive version while search keeps serving the old one; only `activateIndexVersion()` flips traffic. If a sync appears to do nothing, check whether it wrote to a non-active version via `getIndexInfo()`. `AdminSdk` lists raw physical indices, not aliases. -Getting this wrong does not error — it silently breaks search relevance in one language (fields land under the wrong name; the engine's per-language analyzers never see them). Treat any locale-touching diff as high-risk; cover both the unsuffixed default and the suffixed path with tests. +## Price correctness (recurring bug class) -**Known debt**: V1's embeddable-fields builder hardcodes the locale pair — `src/SynchronizationApiSdk.php:348-349` (`$locales = ['en-US', 'lt-LT'];`). This blocks V1 customers outside that pair. It is a known gap; fixing it needs its own ticket (changes V1 index mappings) — do not fix it as a drive-by. +- **Shopify money math is bcmath-only, mandatorily**: `ShopifyAdapter` refuses to construct without `bccomp` and compares prices with `bccomp(..., 2)`. Never replace bcmath comparisons with float `>`/`==`. +- bcmath is Shopify-only — `PrestaShopAdapterV2`/`MagentoAdapterV2` use native numerics with explicit zero-price guards. This asymmetry is historical, not principled; keep zero-price guards intact if you touch non-Shopify price code. +- Zero/empty prices are legitimate inputs from every platform — treat as "no discount", never divide by them. ## Development commands @@ -92,30 +95,10 @@ composer install composer update ``` -## Code architecture - -### Key components -- **`SynchronizationApiSdk`** (V1) / **`SyncV2Sdk`** (V2) — main facades. -- **Field Configuration** (`src/Models/`) — `FieldConfig`, `FieldConfigBuilder`, `FieldType` enum (V1). -- **ValueObjects** (`src/V2/ValueObjects/`) — V2 payload types with constructor validation and builders. -- **Validation** (`src/Validators/DataValidator.php`) — V1 client-side validation before any API call. -- **HTTP** (`src/Client/`) — `HttpClient` (V1), `AdminHttpClient` (admin API). -- **Adapters** (`src/Adapters/`) — `PrestaShopAdapter`/`PrestaShopAdapterV2`, `MagentoAdapter`/`MagentoAdapterV2` (GraphQL-fed via `src/Magento/`), `ShopifyAdapter` (V2-only). - -### targetIndex / alias semantics - -The API exposes versioned physical indices behind an alias named after the appId. `SyncConfigV2->targetIndex` defaults to `null`, so bulk ops normally hit the alias (the LIVE index). During a zero-downtime reindex, construct a second `SyncConfigV2` with `targetIndex` set to the new versioned index so bulk-loading targets the inactive version while search keeps serving the old one; only `activateIndexVersion()` flips traffic. If a sync appears to do nothing, check whether it wrote to a non-active version via `getIndexInfo()`. `AdminSdk` lists raw physical indices, not aliases. - -### Price correctness (recurring bug class) - -- **Shopify money math is bcmath-only, mandatorily**: `ShopifyAdapter` refuses to construct without `bccomp` and compares prices with `bccomp(..., 2)`. Never replace bcmath comparisons with float `>`/`==`. -- bcmath is Shopify-only — `PrestaShopAdapterV2`/`MagentoAdapterV2` use native numerics with explicit zero-price guards. This asymmetry is historical, not principled; keep zero-price guards intact if you touch non-Shopify price code. -- Zero/empty prices are legitimate inputs from every platform — treat as "no discount", never divide by them. - -### Who consumes this SDK +## Who consumes this SDK -- The primary consumer is a **Laravel application**, resolving `bradsearch/search-sync-sdk` from packagist.org (no `repositories` block). Local dev uses a composer path-repository symlink to your checkout. -- The **PrestaShop module** does NOT depend on this SDK — it targets PHP >= 7.1 and owns its own product transformation. +- The primary consumer is a Laravel application, resolving `bradsearch/search-sync-sdk` from packagist.org (no `repositories` block). Local dev uses a composer path-repository symlink to your checkout. +- A separate PrestaShop module does NOT depend on this SDK — it targets an older PHP baseline and owns its own product transformation. - Releases are git tags (`vMAJOR.MINOR.PATCH`); there is no publish workflow in this repo. ## Dependencies diff --git a/src/V2/ValueObjects/CLAUDE.md b/src/V2/ValueObjects/CLAUDE.md new file mode 100644 index 0000000..8cfdce0 --- /dev/null +++ b/src/V2/ValueObjects/CLAUDE.md @@ -0,0 +1,34 @@ +# V2 ValueObjects + +The V2 payload layer. Every class here represents one shape from the server's OpenAPI spec — see the root `CLAUDE.md` for the fixture-parity discipline these exist to satisfy. + +## Conventions (design contract: `tasks/prd-v2-valueobjects.md`) + +- All ValueObjects extend `ValueObject` (this directory) and are declared `readonly`: immutable, constructed once, never mutated in place. +- `jsonSerialize()` (required by `ValueObject`) must return the exact API-compatible array — key names, nesting, and presence/omission of optional keys must match the OpenAPI example byte-for-byte. `toArray()` is a named alias for the same thing. +- Validate in the constructor, not in a separate step — an invalid ValueObject should be impossible to construct. +- Prefer a `with*()` method over a public setter for any change to an existing instance (see `LocalizedField::withLocale()` for the pattern) — it returns a new instance, the original is untouched. +- Non-trivial request types get a companion `*Builder` (e.g. `IndexCreateRequestBuilder`, `ProductBuilder`, `QueryConfigurationRequestBuilder`, `SearchSettingsRequestBuilder`, `FieldDefinitionBuilder`, `SearchFieldConfigBuilder`) so callers can assemble a payload incrementally instead of a single large constructor call. +- Response-side types (`Response/`) are parsed the other direction: a `fromArray()` (or equivalent named constructor) instead of `jsonSerialize()`. Harden these against malformed/partial API responses — server responses are not as tightly controlled as the requests we build ourselves, and a response type should degrade gracefully rather than throw on an unexpected shape. + +## Directory map + +| Directory | Covers | +|---|---| +| `BulkOperations/` | Index/update/delete product payloads sent to the bulk-sync endpoint | +| `Index/` | Index creation: field definitions, field types, variant attributes, search analysis | +| `Search/` | Query configuration (boost algorithm, match mode, multi-word operator, field config) | +| `SearchSettings/` | The larger per-application search-behavior document: query/scoring/response config, highlighting, multi-match, function-score, variant enrichment | +| `Synonym/` | Synonym configuration | +| `Normalize/` | Field-value normalization requests | +| `Product/` | Shared product-level value types (pricing, image URLs) | +| `Response/` | Parsed API responses for all of the above | +| `Common/` | Cross-cutting helpers — currently `LocalizedField` (see root `CLAUDE.md`'s locale-suffix contract) | + +## Known trap: dual-shape parsing + +At least one config type (synonym groups) can arrive from the API as either a comma-separated string or an array of strings, and both forms must normalize to the same internal representation. When a ValueObject accepts more than one input shape for the same concept, keep the normalization in one shared place (see `Synonym/NormalizesSynonymGroups.php`) rather than duplicating the branch logic — a fix applied to only one branch is a recurring source of bugs here. + +## Adding or changing a ValueObject + +Don't do this in isolation — follow the full lockstep checklist in the root `CLAUDE.md` (OpenAPI spec first, then ValueObject, builder, fixture, tests). A ValueObject change that isn't backed by a fixture is not verified, no matter how correct it looks.