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
67 changes: 65 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:

jobs:
test:
Expand Down Expand Up @@ -34,8 +35,34 @@ jobs:
- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Run tests
run: vendor/bin/phpunit --colors=always
- name: Run unit tests
run: vendor/bin/phpunit --colors=always --testsuite=Unit

phpstan:
runs-on: ubuntu-latest
name: PHPStan

strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4', '8.5']

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: curl, json
coverage: none

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Run PHPStan
run: vendor/bin/phpstan analyse --memory-limit=512M

code-style:
runs-on: ubuntu-latest
Expand All @@ -57,3 +84,39 @@ jobs:

- name: Check PHP syntax
run: find src tests -name "*.php" -exec php -l {} \;

integration:
runs-on: ubuntu-latest
name: Integration (real API)

# Skip on PRs from forks (they have no access to repository secrets).
# Runs on push to master/main, on PRs from the same repository, and on manual dispatch.
if: |
github.event_name == 'workflow_dispatch'
|| github.event_name == 'push'
|| (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)

# Serialize integration runs so concurrent jobs do not race on the same test e-shop.
concurrency:
group: integration-${{ github.ref }}
cancel-in-progress: false

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: curl, json
coverage: none

- name: Install dependencies
run: composer install --prefer-dist --no-progress

- name: Run integration tests
env:
POBO_API_TOKEN: ${{ secrets.POBO_API_TOKEN }}
POBO_BASE_URL: https://api.pobo.space
run: vendor/bin/phpunit --colors=always --testsuite=Integration --display-skipped
206 changes: 184 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ composer require pobo-builder/php-sdk

## Quick Start

### Authentication

The SDK uses bearer token authentication. You can obtain your **API token** and **webhook secret** in your Pobo administration under the e-shop settings (REST API section).

The token is sent as `Authorization: Bearer {token}` on every request and is bound to a single e-shop.

### API Client

```php
Expand All @@ -43,6 +49,18 @@ $client = new PoboClient(
4. Blogs (no dependencies)
```

### Multilang Validation Rules

The API enforces a "language consistency" rule across multilang fields:

- `name.default` and `url.default` are **always required**.
- If any language key (e.g. `sk`) appears in any multilang field of an item, then `name.{lang}` and `url.{lang}` become required for that item.
- Other multilang fields (`short_description`, `description`, `seo_title`, `seo_description`) may have `null` values, but the language key must be present in the field if used elsewhere.
- All `url.*` values must start with `https://`.
- Records that fail validation are **skipped**, not aborted — the response reports them in `errors[]` with `index`, `id`, and a list of messages. The rest of the batch is imported.

> **Tip:** The `LocalizedString::withTranslation()` helper sets the value for a given language; use it consistently across `name`, `url` and other fields when adding a new locale to an item.

### Import Parameters

```php
Expand Down Expand Up @@ -234,6 +252,8 @@ echo sprintf('Deleted: %d', $result->deleted);

## Export

> **Important:** When `isEdited` is not provided, the server applies a default of `true` and returns **only products/categories/blogs that have been edited in Pobo** (`is_loaded = true`). To export everything, pass `isEdited: false` explicitly.

### Export Products

```php
Expand All @@ -250,13 +270,16 @@ foreach ($client->iterateProducts() as $product) {
echo sprintf("%s: %s\n", $product->id, $product->name->getDefault());
}

// Filter by last update time
// Filter by last update time (sent to API as Y-m-d H:i:s in server timezone)
$since = new DateTime('2024-01-01 00:00:00');
$response = $client->getProducts(lastUpdateFrom: $since);

// Filter only edited products
// Filter only edited products (= is_loaded on server side)
$response = $client->getProducts(isEdited: true);

// Get ALL products including ones never edited in Pobo
$response = $client->getProducts(isEdited: false);

// Include optional content (marketplace HTML, raw widget JSON)
use Pobo\Sdk\Enum\IncludeContent;

Expand Down Expand Up @@ -293,14 +316,42 @@ foreach ($client->iterateBlogs() as $blog) {
}
```

## Language Filtering

By default, only the `default` language is returned. Use the `lang` parameter to request specific languages:

```php
use Pobo\Sdk\Enum\Language;

// Get all languages
$response = $client->getProducts(lang: [Language::ALL]);

// Get specific languages
$response = $client->getProducts(lang: [Language::DEFAULT, Language::CS, Language::SK]);

// Iterate with language filter
foreach ($client->iterateProducts(lang: [Language::ALL]) as $product) {
echo $product->name->get(Language::CS);
echo $product->content?->getHtml(Language::CS);
}

// Same for categories and blogs
$response = $client->getCategories(lang: [Language::DEFAULT, Language::CS]);
$response = $client->getBlogs(lang: [Language::ALL]);
```

> **Note:** Without the `lang` parameter, only `default` is returned. Invalid language values are silently ignored.

## Content (HTML/Marketplace/Nested)

By default, only `content.html` is returned. Use the `include` parameter to request additional content:

| Value | Description |
|---------------|----------------------------------------------|
| `marketplace` | HTML content for marketplace (no custom CSS) |
| `nested` | Raw widget JSON from widget tables |
| Value | Description | Available for |
|----------------|---------------------------------------------------|--------------------------|
| `marketplace` | HTML content for marketplace (no custom CSS) | product, category, blog |
| `nested` | Raw widget JSON from widget tables | product, category, blog |
| `site_link` | Anchor navigation on H2 headings | product, blog |
| `rich_snippet` | JSON-LD structured data (FAQPage) | product, category, blog |

```php
use Pobo\Sdk\Enum\IncludeContent;
Expand Down Expand Up @@ -344,8 +395,79 @@ foreach ($client->iterateBlogs(include: [IncludeContent::MARKETPLACE]) as $blog)
}
```

## Site Links

Anchor navigation generated from H2 headings in content widgets. Available for products and blogs.

> **E-shop config required:** Site links are returned only if `enable_site_link` is enabled on the e-shop in Pobo administration. Without that flag the `site_link` field will be empty even when `IncludeContent::SITE_LINK` is requested.

```php
use Pobo\Sdk\Enum\IncludeContent;
use Pobo\Sdk\Enum\Language;

foreach ($client->iterateProducts(include: [IncludeContent::SITE_LINK], lang: [Language::ALL]) as $product) {
if ($product->siteLink !== null) {
// Get rendered navigation HTML
$navHtml = $product->siteLink->getHtml(Language::DEFAULT);

// Get structured list of headings
$items = $product->siteLink->getList(Language::DEFAULT);
foreach ($items as $item) {
echo sprintf('<a href="#%s">%s</a>', $item->slug, $item->heading);
}
}
}
```

## Rich Snippets

JSON-LD structured data (FAQPage schema) generated from FAQ widgets. Available for products, categories, and blogs.

> **E-shop config required:** Rich snippets are returned only if `enable_rich_snippet` is enabled on the e-shop in Pobo administration. Without that flag the `rich_snippet` field will be empty even when `IncludeContent::RICH_SNIPPET` is requested.

```php
use Pobo\Sdk\Enum\IncludeContent;
use Pobo\Sdk\Enum\Language;

foreach ($client->iterateProducts(include: [IncludeContent::RICH_SNIPPET], lang: [Language::ALL]) as $product) {
if ($product->richSnippet !== null) {
// Get rendered JSON-LD script tag
$scriptHtml = $product->richSnippet->getHtml(Language::DEFAULT);

// Get parsed JSON-LD object
$jsonLd = $product->richSnippet->getJson(Language::DEFAULT);
echo $jsonLd['@type']; // 'FAQPage'
}
}

// Categories have rich snippets but no site links
foreach ($client->iterateCategories(include: [IncludeContent::RICH_SNIPPET]) as $category) {
if ($category->richSnippet !== null) {
echo $category->richSnippet->getHtml(Language::DEFAULT);
}
}
```

## Webhook Handler

Pobo can notify your application when content is changed in the administration. You register a webhook URL in the Pobo e-shop settings and receive `POST` requests signed with HMAC-SHA256.

### Events

| Event | Constant | Fired when |
|----------------------|-----------------------------------|----------------------------------------------------|
| `Products.update` | `WebhookEvent::PRODUCTS_UPDATE` | Any product content was edited and saved in Pobo |
| `Categories.update` | `WebhookEvent::CATEGORIES_UPDATE` | Any category content was edited and saved in Pobo |
| `Blogs.update` | `WebhookEvent::BLOGS_UPDATE` | Any blog content was edited and saved in Pobo |

Each request carries:

- Header `X-Webhook-Signature` — HMAC-SHA256 of the raw body using your webhook secret.
- Header `X-Webhook-Event` — event name (informational; the SDK reads the event from the body).
- JSON body — `{ "event": "...", "timestamp": "ISO-8601", "eshop_id": 123 }`.

The webhook does **not** carry the changed entities themselves; it is a notification to trigger a sync via the export endpoints (`getProducts` / `iterateProducts` etc., typically combined with `lastUpdateFrom`).

### Basic Usage

```php
Expand Down Expand Up @@ -408,6 +530,16 @@ try {
}
```

### Exception Types

| Exception | Thrown when |
|------------------------|---------------------------------------------------------------------------------------------------------------------------|
| `ValidationException` | Local pre-flight checks fail — empty payload or **more than 100 items** in a bulk import/delete. The HTTP request is not sent. |
| `ApiException` | The HTTP request fails (network/cURL error) or the API returns `>= 400`. `httpCode` and parsed `responseBody` are exposed. |
| `WebhookException` | The webhook signature is missing/invalid, the body cannot be parsed, or the event name is unknown. |

> **Note:** Per-item validation failures during bulk import (e.g. invalid URL, missing language) do **not** raise `ApiException`. They appear in `$result->errors[]` while the rest of the batch is processed. Always inspect `$result->hasErrors()` after a successful call.

## Localized Strings

```php
Expand All @@ -429,6 +561,8 @@ $name->get(Language::CS); // 'Czech Name'
$name->toArray(); // ['default' => '...', 'cs' => '...', ...]
```

> **About `default`:** `default` is a separate, virtual locale — **not an alias for `cs` or any other language**. It is the fallback content used by Pobo when a specific language variant is missing. `name.default` and `url.default` are required on import; per-language values (`cs`, `sk`, …) are optional and follow the multilang validation rule above.

### Supported Languages

| Code | Language |
Expand All @@ -443,21 +577,21 @@ $name->toArray(); // ['default' => '...', 'cs' => '...', ...]

## API Methods

| Method | Description |
|---------------------------------------------------------------------------------------------------------|----------------------------------|
| `importProducts(array $products)` | Bulk import products (max 100) |
| `importCategories(array $categories)` | Bulk import categories (max 100) |
| `importParameters(array $parameters)` | Bulk import parameters (max 100) |
| `importBlogs(array $blogs)` | Bulk import blogs (max 100) |
| `deleteProducts(array $ids)` | Bulk delete products (max 100) |
| `deleteCategories(array $ids)` | Bulk delete categories (max 100) |
| `deleteBlogs(array $ids)` | Bulk delete blogs (max 100) |
| `getProducts(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Get products page |
| `getCategories(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Get categories page |
| `getBlogs(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Get blogs page |
| `iterateProducts(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Iterate all products |
| `iterateCategories(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Iterate all categories |
| `iterateBlogs(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include)` | Iterate all blogs |
| Method | Description |
|-----------------------------------------------------------------------------------------------------------------------|----------------------------------|
| `importProducts(array $products)` | Bulk import products (max 100) |
| `importCategories(array $categories)` | Bulk import categories (max 100) |
| `importParameters(array $parameters)` | Bulk import parameters (max 100) |
| `importBlogs(array $blogs)` | Bulk import blogs (max 100) |
| `deleteProducts(array $ids)` | Bulk delete products (max 100) |
| `deleteCategories(array $ids)` | Bulk delete categories (max 100) |
| `deleteBlogs(array $ids)` | Bulk delete blogs (max 100) |
| `getProducts(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get products page |
| `getCategories(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get categories page |
| `getBlogs(?int $page, ?int $perPage, ?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Get blogs page |
| `iterateProducts(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all products |
| `iterateCategories(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all categories |
| `iterateBlogs(?DateTime $lastUpdateFrom, ?bool $isEdited, ?array $include, ?array $lang)` | Iterate all blogs |

## Limits

Expand All @@ -470,9 +604,37 @@ $name->toArray(); // ['default' => '...', 'cs' => '...', ...]
| Name length | 250 chars |
| URL length | 255 chars |
| Image URL length | 650 chars |
| Description length | 65,000 chars |
| Description length | 500,000 chars |
| SEO description length | 500 chars |

## Testing

### Unit Tests

```bash
vendor/bin/phpunit --testsuite=Unit
```

Unit tests are pure (no network) and run on every CI build across PHP 8.1–8.5.

### Integration Tests

Integration tests hit the real `api.pobo.space` API and verify that the SDK's wire format is in sync with the server. They require a dedicated test e-shop and a valid REST API token.

```bash
POBO_API_TOKEN="your-test-eshop-token" vendor/bin/phpunit --testsuite=Integration
```

If `POBO_API_TOKEN` is not set, all tests in the suite are marked **skipped** (not failed).

The CI workflow runs integration tests automatically on:

- pushes to `master`,
- PRs **from the same repository** (PRs from forks are skipped — secrets are not exposed there),
- manual dispatch (`workflow_dispatch`).

Each run uses a unique ID prefix combining `GITHUB_RUN_ID` and a random suffix, and `tearDown()` removes anything the test created. Two PRs running simultaneously cannot collide.

## License

MIT License
Loading
Loading