mirror of
https://github.com/wearepixel/laravel-google-shopping-feed.git
synced 2026-11-14 21:31:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37bc2b6af9 | ||
|
|
184e29003b | ||
|
|
6a851de35e | ||
|
|
4eac969816 | ||
|
|
c4439309eb | ||
|
|
f618408877 | ||
|
|
cb067ccb77 | ||
|
|
af17f7b868 | ||
|
|
d694586a65 | ||
|
|
db8871110a | ||
|
|
1c426d0d1b | ||
|
|
8f0ae03c4b | ||
|
|
428c310525 | ||
|
|
f9dcce39b9 | ||
|
|
a7e22e8fce | ||
|
|
5a1c822810 | ||
|
|
fb85d49e88 | ||
|
|
b08e9943b8 | ||
|
|
01ac326a54 | ||
|
|
ebac71996c |
@@ -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@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@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);
|
|
||||||
@@ -25,8 +25,8 @@ ## Example
|
|||||||
'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',
|
||||||
'image_link' => 'https://mystore.com/images/blue-nikes-001.jpg',
|
'g:image_link' => 'https://mystore.com/images/blue-nikes-001.jpg',
|
||||||
'price' => 29.99,
|
'g:price' => 29.99,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $feed->generate();
|
return $feed->generate();
|
||||||
|
|||||||
+45
-24
@@ -1,26 +1,47 @@
|
|||||||
{
|
{
|
||||||
"name": "wearepixel/laravel-google-shopping-feed",
|
"name": "wearepixel/laravel-google-shopping-feed",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"description": "Laravel Google Shopping Feed",
|
"description": "Laravel Google Shopping Feed",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"laravel",
|
"laravel",
|
||||||
"google-shopping-feed"
|
"google-shopping-feed"
|
||||||
],
|
],
|
||||||
"version": "1.0.0",
|
"version": "5.1.0",
|
||||||
"homepage": "https://github.com/wearepixel/laravel-google-shopping-feed",
|
"homepage": "https://github.com/wearepixel/laravel-google-shopping-feed",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"name": "Pixel",
|
"name": "Pixel",
|
||||||
"email": "hello@wearepixel.com.au"
|
"email": "hello@wearepixel.com.au"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": { "Wearepixel\\LaravelGoogleShoppingFeed\\" : "src/" }
|
"psr-4": {
|
||||||
},
|
"Wearepixel\\LaravelGoogleShoppingFeed\\": "src/"
|
||||||
"require": {
|
}
|
||||||
"spatie/array-to-xml": "2.8.*|3.*.*",
|
},
|
||||||
"illuminate/support": "8.*|9.*"
|
"autoload-dev": {
|
||||||
},
|
"psr-4": {
|
||||||
"prefer-stable": true
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"spatie/array-to-xml": "2.8.*|3.*",
|
||||||
|
"illuminate/support": "10.*|11.*|12.*|13.*"
|
||||||
|
},
|
||||||
|
"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
+9022
-240
File diff suppressed because it is too large
Load Diff
+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',
|
||||||
'price',
|
'g:price',
|
||||||
'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,31 +35,21 @@ 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$product = [
|
$this->products[] = $item;
|
||||||
'id' => $item['id'],
|
|
||||||
'link' => $item['link'],
|
|
||||||
'title' => $item['title'],
|
|
||||||
'price' => $item['price'],
|
|
||||||
'image_link' => $item['image_link'],
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->products[] = $product;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate()
|
public function toXml(): string
|
||||||
{
|
{
|
||||||
$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',
|
||||||
'version' => '2.0',
|
'version' => '2.0',
|
||||||
@@ -66,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