Skip to content
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
181 changes: 94 additions & 87 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,103 +1,110 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.

## Development Commands
## What this is

`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 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 architecture

- **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).

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 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 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 spec first — never invent a shape SDK-side.
2. Update the ValueObject in `src/V2/ValueObjects/<area>/`.
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 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 ValueObject — 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 `<baseName>_<locale>` 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.

## 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.

## 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
## 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.
- 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

- **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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 27 additions & 1 deletion src/SyncV2Sdk.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -216,7 +220,29 @@ public function getSynonyms(string $language): SynonymResponse
$this->baseApiPath . 'synonyms?language=' . urlencode($language)
);

return SynonymResponse::fromArray($response);
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<string, mixed> $response
* @return array<string, mixed>
*/
private function withSynonymGetDefaults(array $response, string $language): array
{
$synonyms = $response['synonyms'] ?? [];
$synonyms = is_array($synonyms) ? $synonyms : [];

return [
'language' => $response['language'] ?? $language,
'synonym_count' => $response['synonym_count'] ?? count($synonyms),
'requires_reindex' => $response['requires_reindex'] ?? false,
'synonyms' => $synonyms,
];
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/V2/ValueObjects/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading