mirror of
https://github.com/wearepixel/laravel-google-shopping-feed.git
synced 2026-11-14 21:31:46 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0318b5337f | ||
|
|
47d17da306 | ||
|
|
266e7c4037 | ||
|
|
6b70f2352f | ||
|
|
8e0f0a16f2 | ||
|
|
82a502054a | ||
|
|
a034a28393 | ||
|
|
20e10157ef | ||
|
|
c09e9005e7 | ||
|
|
0f81aaa907 | ||
|
|
82d39281bc | ||
|
|
ea691bb1e2 | ||
|
|
c6114f0794 | ||
|
|
03bc523e30 | ||
|
|
4c01c57690 | ||
|
|
0e10917305 | ||
|
|
006ef90eb9 | ||
|
|
a90def1ac7 | ||
|
|
ad889bb4c4 | ||
|
|
292b2b12d7 | ||
|
|
a0d2837bd7 | ||
|
|
13d90a0f08 | ||
|
|
37bc2b6af9 | ||
|
|
184e29003b | ||
|
|
6a851de35e | ||
|
|
4eac969816 | ||
|
|
c4439309eb | ||
|
|
f618408877 | ||
|
|
cb067ccb77 | ||
|
|
af17f7b868 | ||
|
|
d694586a65 | ||
|
|
db8871110a | ||
|
|
1c426d0d1b | ||
|
|
8f0ae03c4b | ||
|
|
428c310525 |
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[docker-compose.yml]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
applies-to: version-updates
|
||||||
|
patterns: ["*"]
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
include: "scope"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
|
||||||
|
- package-ecosystem: "composer"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "tuesday"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
php-minor-patch:
|
||||||
|
applies-to: version-updates
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
php-security:
|
||||||
|
applies-to: security-updates
|
||||||
|
patterns: ["*"]
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore"
|
||||||
|
include: "scope"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Dependabot Auto-merge
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Tests"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'pull_request' &&
|
||||||
|
github.event.workflow_run.actor.login == 'dependabot[bot]'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Find PR and check update type
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
run: |
|
||||||
|
PR_DATA=$(gh pr list --repo "${{ github.repository }}" --head "$HEAD_BRANCH" --json number,title,url,author --jq '.[0]')
|
||||||
|
|
||||||
|
PR_URL=$(echo "$PR_DATA" | jq -r '.url')
|
||||||
|
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||||
|
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||||
|
|
||||||
|
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
if [ "$PR_AUTHOR" != "app/dependabot" ]; then
|
||||||
|
echo "is_safe=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Not a Dependabot PR, skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse major version numbers from title: "bump X from A[.B.C] to D[.E.F]"
|
||||||
|
# Works for both "from 4.0.0 to 4.1.0" and "from 4 to 6" (GitHub Actions)
|
||||||
|
FROM_MAJOR=$(echo "$PR_TITLE" | grep -oP 'from \K[0-9]+')
|
||||||
|
TO_MAJOR=$(echo "$PR_TITLE" | grep -oP 'to \K[0-9]+')
|
||||||
|
|
||||||
|
echo "PR: $PR_TITLE (major: $FROM_MAJOR -> $TO_MAJOR)"
|
||||||
|
|
||||||
|
if [ "$FROM_MAJOR" = "$TO_MAJOR" ]; then
|
||||||
|
echo "is_safe=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "is_safe=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Major version bump, skipping auto-merge."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Merge minor and patch updates
|
||||||
|
if: steps.check.outputs.is_safe == 'true'
|
||||||
|
run: gh pr merge --merge "${{ steps.check.outputs.pr_url }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
name: Dependabot Slack Alert
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
name: Notify Slack of major update
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
steps:
|
||||||
|
- name: Check if major version bump
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
run: |
|
||||||
|
from_major=$(echo "$PR_TITLE" | grep -oP 'from \K[0-9]+' | head -1)
|
||||||
|
to_major=$(echo "$PR_TITLE" | grep -oP 'to \K[0-9]+' | head -1)
|
||||||
|
|
||||||
|
if [ -n "$from_major" ] && [ -n "$to_major" ] && [ "$from_major" != "$to_major" ]; then
|
||||||
|
echo "is_major=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Major bump detected: $from_major -> $to_major"
|
||||||
|
else
|
||||||
|
echo "is_major=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Minor/patch bump, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Send Slack alert
|
||||||
|
if: steps.check.outputs.is_major == 'true'
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$SLACK_WEBHOOK_URL" ]; then
|
||||||
|
echo "SLACK_WEBHOOK_URL not configured for this repo, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=$(jq -n \
|
||||||
|
--arg repo "$REPO" \
|
||||||
|
--arg title "$PR_TITLE" \
|
||||||
|
--arg url "$PR_URL" \
|
||||||
|
--arg pr "#$PR_NUMBER" \
|
||||||
|
'{
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: ":dependabot: *Major dependency update requires review*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
fields: [
|
||||||
|
{type: "mrkdwn", text: ("*Repository*\n`" + $repo + "`")},
|
||||||
|
{type: "mrkdwn", text: ("*Pull Request*\n<" + $url + "|" + $pr + ">") }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: $title
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "context",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: "Major version bumps are not auto-merged as they may contain breaking changes."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "actions",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
text: {type: "plain_text", text: "Review PR"},
|
||||||
|
url: $url,
|
||||||
|
style: "primary"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}')
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -X POST "$SLACK_WEBHOOK_URL" \
|
||||||
|
-H "Content-type: application/json" \
|
||||||
|
--data "$payload" | grep -q "^200$" \
|
||||||
|
&& echo "Slack notification sent" \
|
||||||
|
|| echo "Slack notification failed"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
php: ["8.4"]
|
||||||
|
|
||||||
|
name: PHP ${{ matrix.php }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
extensions: dom, curl, libxml, mbstring, zip
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --prefer-dist --no-interaction --no-progress
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: vendor/bin/pest
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
$finder = PhpCsFixer\Finder::create()
|
|
||||||
->exclude('bootstrap/cache')
|
|
||||||
->exclude('storage')
|
|
||||||
->exclude('vendor')
|
|
||||||
->in(__DIR__)
|
|
||||||
->name('*.php')
|
|
||||||
->notName('*.blade.php')
|
|
||||||
->ignoreDotFiles(true)
|
|
||||||
->ignoreVCS(true);
|
|
||||||
|
|
||||||
$rules = [
|
|
||||||
'@PSR12' => true,
|
|
||||||
'array_syntax' => ['syntax' => 'short'],
|
|
||||||
'binary_operator_spaces' => ['operators' => ['=>' => null]],
|
|
||||||
'linebreak_after_opening_tag' => true,
|
|
||||||
'ordered_imports' => ['sort_algorithm' => 'length'],
|
|
||||||
'phpdoc_order' => true,
|
|
||||||
'phpdoc_no_empty_return' => false,
|
|
||||||
'yoda_style' => false,
|
|
||||||
'no_unused_imports' => true,
|
|
||||||
];
|
|
||||||
|
|
||||||
$config = new PhpCsFixer\Config();
|
|
||||||
|
|
||||||
return $config->setRules($rules)
|
|
||||||
->setFinder($finder)
|
|
||||||
->setUsingCache(false);
|
|
||||||
@@ -1,33 +1,84 @@
|
|||||||
Laravel Google Shopping Feed
|

|
||||||
==============
|
|
||||||
|
|
||||||
A simple package to easily create an XML feed for Google Merchant Center to parse and retrieve your products on a frequent basis.
|
[](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
|
||||||
|
[](https://github.com/wearepixel/laravel-google-shopping-feed/actions/workflows/tests.yml)
|
||||||
|
[](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
|
||||||
|
[](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
|
||||||
|
[](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
|
||||||
|
|
||||||
We recommend adding this to an API controller and generating it on the fly each time so you can be sure your products are always up to date within your merchant center.
|
A simple package to easily create an XML feed for Google Merchant Center to parse and retrieve your products.
|
||||||
|
|
||||||
|
We recommend adding this to an API controller and generating it on the fly so your feed is always up to date.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.4+
|
||||||
|
- Laravel 10, 11, or 12
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```
|
```bash
|
||||||
composer require wearepixel/laravel-google-shopping-feed
|
composer require wearepixel/laravel-google-shopping-feed
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example
|
## Usage
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed;
|
use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed;
|
||||||
|
|
||||||
$feed = LaravelGoogleShopping::init(
|
$feed = LaravelGoogleShoppingFeed::init(
|
||||||
'Product Feed',
|
title: 'My Store',
|
||||||
'App product Feed',
|
description: 'My store product feed',
|
||||||
'https://mystore.com'
|
link: 'https://mystore.com',
|
||||||
);
|
);
|
||||||
|
|
||||||
$feed->addItem([
|
$feed->addItem([
|
||||||
'id' => 'item_001',
|
'id' => 'item_001',
|
||||||
'title' => 'Blue Nikes',
|
'title' => 'Blue Nikes',
|
||||||
'link' => 'https://mystore.com/products/blue-nikes',
|
'link' => 'https://mystore.com/products/blue-nikes',
|
||||||
|
'g:price' => '29.99 AUD',
|
||||||
'g:image_link' => 'https://mystore.com/images/blue-nikes-001.jpg',
|
'g:image_link' => 'https://mystore.com/images/blue-nikes-001.jpg',
|
||||||
'g:price' => 29.99,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $feed->generate();
|
return $feed->generate();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Required item fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| -------------- | ------------------------------------------------ |
|
||||||
|
| `id` | Unique product identifier |
|
||||||
|
| `title` | Product name |
|
||||||
|
| `link` | URL to the product page |
|
||||||
|
| `g:price` | Price including currency code (e.g. `29.99 AUD`) |
|
||||||
|
| `g:image_link` | URL to the main product image |
|
||||||
|
|
||||||
|
Any additional fields supported by the [Google Product Data Specification](https://support.google.com/merchants/answer/7052112) can be passed alongside the required fields.
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
**`LaravelGoogleShoppingFeed::init(string $title, string $description, string $link): static`**
|
||||||
|
|
||||||
|
Creates a new feed instance with channel metadata.
|
||||||
|
|
||||||
|
**`$feed->addItem(array $item): void`**
|
||||||
|
|
||||||
|
Adds a product to the feed. Throws `MissingRequiredFieldException` if any required field is absent.
|
||||||
|
|
||||||
|
**`$feed->toXml(): string`**
|
||||||
|
|
||||||
|
Returns the feed as an XML string.
|
||||||
|
|
||||||
|
**`$feed->generate(): Response`**
|
||||||
|
|
||||||
|
Returns an HTTP response with the XML content and `application/rss+xml` content type. Suitable for returning directly from a controller.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability, please report it privately using the below channel:
|
||||||
|
|
||||||
|
**GitHub Private Vulnerability Reporting** — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
|
||||||
|
|
||||||
|
All security vulnerabilities will be promptly addressed.
|
||||||
+26
-5
@@ -6,7 +6,7 @@
|
|||||||
"laravel",
|
"laravel",
|
||||||
"google-shopping-feed"
|
"google-shopping-feed"
|
||||||
],
|
],
|
||||||
"version": "4.0.0",
|
"version": "5.1.1",
|
||||||
"homepage": "https://github.com/wearepixel/laravel-google-shopping-feed",
|
"homepage": "https://github.com/wearepixel/laravel-google-shopping-feed",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"authors": [
|
"authors": [
|
||||||
@@ -16,11 +16,32 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": { "Wearepixel\\LaravelGoogleShoppingFeed\\" : "src/" }
|
"psr-4": {
|
||||||
|
"Wearepixel\\LaravelGoogleShoppingFeed\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"spatie/array-to-xml": "2.8.*|3.*.*",
|
"php": ">=8.4",
|
||||||
"illuminate/support": "8.*|9.*|10.*|11.*"
|
"spatie/array-to-xml": "2.8.*|3.*",
|
||||||
|
"illuminate/support": "10.*|11.*|12.*|13.*"
|
||||||
},
|
},
|
||||||
"prefer-stable": true
|
"prefer-stable": true,
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.29",
|
||||||
|
"pestphp/pest": "^4.0",
|
||||||
|
"orchestra/testbench": "^10.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vendor/bin/pest"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9006
-261
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"preset": "laravel",
|
||||||
|
"rules": {
|
||||||
|
"concat_space": {
|
||||||
|
"spacing": "one"
|
||||||
|
},
|
||||||
|
"class_attributes_separation": false,
|
||||||
|
"ordered_imports": {
|
||||||
|
"sort_algorithm": "length"
|
||||||
|
},
|
||||||
|
"not_operator_with_space": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Wearepixel\LaravelGoogleShoppingFeed\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class MissingRequiredFieldException extends RuntimeException {}
|
||||||
@@ -1,31 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Wearepixel\LaravelGoogleShoppingFeed;
|
namespace Wearepixel\LaravelGoogleShoppingFeed;
|
||||||
|
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Response as ResponseFacade;
|
||||||
use Spatie\ArrayToXml\ArrayToXml;
|
use Spatie\ArrayToXml\ArrayToXml;
|
||||||
|
use Wearepixel\LaravelGoogleShoppingFeed\Exceptions\MissingRequiredFieldException;
|
||||||
|
|
||||||
class LaravelGoogleShoppingFeed
|
class LaravelGoogleShoppingFeed
|
||||||
{
|
{
|
||||||
public $title;
|
public readonly string $title;
|
||||||
public $description;
|
public readonly string $description;
|
||||||
public $link;
|
public readonly string $link;
|
||||||
|
|
||||||
protected $xml = [];
|
protected array $products = [];
|
||||||
protected $products = [];
|
|
||||||
protected $currency = "AUD";
|
|
||||||
|
|
||||||
protected $requiredProductFields = [
|
protected const array REQUIRED_PRODUCT_FIELDS = [
|
||||||
'id',
|
'id',
|
||||||
'link',
|
'link',
|
||||||
'title',
|
'title',
|
||||||
'g:price',
|
'g:price',
|
||||||
'g:image_link'
|
'g:image_link',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function init($title = null, $description = null, $link = null)
|
public static function init(string $title = '', string $description = '', string $link = ''): static
|
||||||
{
|
{
|
||||||
$feed = new self();
|
$feed = new static;
|
||||||
|
|
||||||
$feed->title = $title;
|
$feed->title = $title;
|
||||||
$feed->description = $description;
|
$feed->description = $description;
|
||||||
$feed->link = $link;
|
$feed->link = $link;
|
||||||
@@ -33,26 +35,20 @@ public static function init($title = null, $description = null, $link = null)
|
|||||||
return $feed;
|
return $feed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addItem(array $item): bool
|
public function addItem(array $item): void
|
||||||
{
|
{
|
||||||
foreach ($this->requiredProductFields as $field) {
|
foreach (self::REQUIRED_PRODUCT_FIELDS as $field) {
|
||||||
if (! isset($item[$field])) {
|
if (! isset($item[$field])) {
|
||||||
throw new \Exception("Required field '{$field}' is missing");
|
throw new MissingRequiredFieldException("Required field '{$field}' is missing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($item as $key => $value) {
|
$this->products[] = $item;
|
||||||
$product[$key] = $value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->products[] = $product;
|
public function toXml(): string
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generate()
|
|
||||||
{
|
{
|
||||||
$this->xml = [
|
$data = [
|
||||||
'rss' => [
|
'rss' => [
|
||||||
'_attributes' => [
|
'_attributes' => [
|
||||||
'xmlns:g' => 'http://base.google.com/ns/1.0',
|
'xmlns:g' => 'http://base.google.com/ns/1.0',
|
||||||
@@ -62,23 +58,25 @@ public function generate()
|
|||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'link' => $this->link,
|
'link' => $this->link,
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($this->products as $key => $product) {
|
foreach ($this->products as $key => $product) {
|
||||||
$this->xml['rss']['channel']['item_'.$key] = $product;
|
$data['rss']['channel']['item_' . $key] = $product;
|
||||||
}
|
}
|
||||||
|
|
||||||
$xml = ArrayToXml::convert($this->xml, '');
|
$xml = ArrayToXml::convert($data, '');
|
||||||
$xml = str_replace([' ', '<root>', '</root>', "\n", "\r", '<remove>remove</remove>'], '', $xml);
|
$xml = str_replace([' ', '<root>', '</root>', "\n", "\r", '<remove>remove</remove>'], '', $xml);
|
||||||
$xml = preg_replace([
|
// spatie/array-to-xml does not support repeated keys, so products are keyed as
|
||||||
"/item_[0-9][0-9][0-9][0-9]/",
|
// item_0, item_1, etc. and renamed back to <item> tags here.
|
||||||
"/item_[0-9][0-9][0-9]/",
|
$xml = (string) preg_replace('/item_\d+/', 'item', $xml);
|
||||||
"/item_[0-9][0-9]/",
|
|
||||||
"/item_[0-9]/",
|
|
||||||
], "item", $xml);
|
|
||||||
|
|
||||||
return response($xml)->header('Content-Type', 'text/xml');
|
return $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(): Response
|
||||||
|
{
|
||||||
|
return ResponseFacade::make($this->toXml(), headers: ['Content-Type' => 'application/rss+xml']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Wearepixel\LaravelGoogleShoppingFeed\Exceptions\MissingRequiredFieldException;
|
||||||
|
use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed;
|
||||||
|
|
||||||
|
function validProduct(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'id' => 'prod-1',
|
||||||
|
'link' => 'https://example.com/product',
|
||||||
|
'title' => 'Test Product',
|
||||||
|
'g:price' => '29.99 AUD',
|
||||||
|
'g:image_link' => 'https://example.com/image.jpg',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('init', function () {
|
||||||
|
it('stores title, description, and link', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Desc', 'https://example.com');
|
||||||
|
|
||||||
|
expect($feed->title)->toBe('My Store')
|
||||||
|
->and($feed->description)->toBe('My Desc')
|
||||||
|
->and($feed->link)->toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to empty strings', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init();
|
||||||
|
|
||||||
|
expect($feed->title)->toBe('')
|
||||||
|
->and($feed->description)->toBe('')
|
||||||
|
->and($feed->link)->toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addItem', function () {
|
||||||
|
it('accepts a valid item without throwing', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('', '', '');
|
||||||
|
$feed->addItem(validProduct());
|
||||||
|
|
||||||
|
expect($feed->toXml())->toContain('prod-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for missing required field', function (string $field) {
|
||||||
|
$product = validProduct();
|
||||||
|
unset($product[$field]);
|
||||||
|
|
||||||
|
expect(fn () => LaravelGoogleShoppingFeed::init()->addItem($product))
|
||||||
|
->toThrow(MissingRequiredFieldException::class);
|
||||||
|
})->with(['id', 'link', 'title', 'g:price', 'g:image_link']);
|
||||||
|
|
||||||
|
it('throws with a message containing the field name', function () {
|
||||||
|
$product = validProduct();
|
||||||
|
unset($product['g:price']);
|
||||||
|
|
||||||
|
expect(fn () => LaravelGoogleShoppingFeed::init()->addItem($product))
|
||||||
|
->toThrow(MissingRequiredFieldException::class, "g:price");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toXml', function () {
|
||||||
|
it('returns a string', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
|
||||||
|
expect($feed->toXml())->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the RSS xmlns:g attribute', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init();
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)->toContain('xmlns:g="http://base.google.com/ns/1.0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the RSS version attribute', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init();
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)->toContain('version="2.0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the channel title, description and link', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Description', 'https://example.com');
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)
|
||||||
|
->toContain('<title>My Store</title>')
|
||||||
|
->toContain('<description>My Description</description>')
|
||||||
|
->toContain('<link>https://example.com</link>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes product data', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$feed->addItem(validProduct());
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)
|
||||||
|
->toContain('<id>prod-1</id>')
|
||||||
|
->toContain('<title>Test Product</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps products in item tags', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$feed->addItem(validProduct());
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)
|
||||||
|
->toContain('<item>')
|
||||||
|
->toContain('</item>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple products', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$feed->addItem(validProduct(['id' => 'prod-1']));
|
||||||
|
$feed->addItem(validProduct(['id' => 'prod-2']));
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect(substr_count($xml, '<item>'))->toBe(2)
|
||||||
|
->and($xml)->toContain('<id>prod-1</id>')
|
||||||
|
->and($xml)->toContain('<id>prod-2</id>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes namespaced google fields in output', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('', '', '');
|
||||||
|
$feed->addItem(validProduct());
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)
|
||||||
|
->toContain('<g:price>29.99 AUD</g:price>')
|
||||||
|
->toContain('<g:image_link>https://example.com/image.jpg</g:image_link>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates valid xml with no products', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Desc', 'https://example.com');
|
||||||
|
$xml = $feed->toXml();
|
||||||
|
|
||||||
|
expect($xml)
|
||||||
|
->toBeString()
|
||||||
|
->toContain('<title>My Store</title>')
|
||||||
|
->not->toContain('<item>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the xml declaration', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('', '', '');
|
||||||
|
expect($feed->toXml())->toStartWith('<?xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates parseable xml', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$feed->addItem(validProduct());
|
||||||
|
|
||||||
|
$result = simplexml_load_string($feed->toXml());
|
||||||
|
expect($result)->not->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generate', function () {
|
||||||
|
it('returns an HTTP 200 response', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$response = $feed->generate();
|
||||||
|
|
||||||
|
expect($response->getStatusCode())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the content type to application/rss+xml', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$response = $feed->generate();
|
||||||
|
|
||||||
|
expect($response->headers->get('Content-Type'))->toContain('application/rss+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the xml in the response body', function () {
|
||||||
|
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
|
||||||
|
$feed->addItem(validProduct());
|
||||||
|
$response = $feed->generate();
|
||||||
|
|
||||||
|
expect($response->getContent())->toBe($feed->toXml());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class)->in('.');
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Orchestra\Testbench\TestCase as BaseTestCase;
|
||||||
|
|
||||||
|
abstract class TestCase extends BaseTestCase {}
|
||||||
Reference in New Issue
Block a user