diff --git a/README.md b/README.md index 3b963a9..a5660fa 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel. -It allows you to manage TRMNL devices, generate screens using native plugins, recipes (55+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 15k downloads and 100+ stars, it’s the most popular community-driven BYOS. +It allows you to manage TRMNL devices, generate screens using native plugins, recipes (55+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, it’s the most popular community-driven BYOS. ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index b372cdd..dfeb757 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -14,6 +14,7 @@ use App\Liquid\Tags\TemplateTag; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Http; @@ -22,6 +23,7 @@ use Illuminate\Support\Str; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Extensions\StandardExtension; +use SimpleXMLElement; class Plugin extends Model { @@ -83,7 +85,7 @@ class Plugin extends Model $currentValue = $this->configuration[$fieldKey] ?? null; // If the field has a default value and no current value is set, it's not missing - if (($currentValue === null || $currentValue === '' || ($currentValue === [])) && ! isset($field['default'])) { + if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) { return true; // Found a required field that is not set and has no default } } @@ -145,11 +147,9 @@ class Plugin extends Model try { // Make the request based on the verb - if ($this->polling_verb === 'post') { - $response = $httpRequest->post($resolvedUrl)->json(); - } else { - $response = $httpRequest->get($resolvedUrl)->json(); - } + $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); + + $response = $this->parseResponse($httpResponse); $this->update([ 'data_payload' => $response, @@ -183,14 +183,12 @@ class Plugin extends Model try { // Make the request based on the verb - if ($this->polling_verb === 'post') { - $response = $httpRequest->post($resolvedUrl)->json(); - } else { - $response = $httpRequest->get($resolvedUrl)->json(); - } + $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); + + $response = $this->parseResponse($httpResponse); // Check if response is an array at root level - if (is_array($response) && array_keys($response) === range(0, count($response) - 1)) { + if (array_keys($response) === range(0, count($response) - 1)) { // Response is a sequential array, nest under .data $combinedResponse["IDX_{$index}"] = ['data' => $response]; } else { @@ -211,6 +209,56 @@ class Plugin extends Model } } + /** + * Parse HTTP response, handling both JSON and XML content types + */ + private function parseResponse(Response $httpResponse): array + { + if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) { + try { + // Convert XML to array and wrap under 'rss' key + $xml = simplexml_load_string($httpResponse->body()); + if ($xml === false) { + throw new Exception('Invalid XML content'); + } + + // Convert SimpleXML directly to array + $xmlArray = $this->xmlToArray($xml); + + return ['rss' => $xmlArray]; + } catch (Exception $e) { + Log::warning('Failed to parse XML response: '.$e->getMessage()); + + return ['error' => 'Failed to parse XML response']; + } + } + + // Default to JSON parsing + try { + return $httpResponse->json() ?? []; + } catch (Exception $e) { + Log::warning('Failed to parse JSON response: '.$e->getMessage()); + + return ['error' => 'Failed to parse JSON response']; + } + } + + /** + * Convert SimpleXML object to array recursively + */ + private function xmlToArray(SimpleXMLElement $xml): array + { + $array = (array) $xml; + + foreach ($array as $key => $value) { + if ($value instanceof SimpleXMLElement) { + $array[$key] = $this->xmlToArray($value); + } + } + + return $array; + } + /** * Apply Liquid template replacements (converts 'with' syntax to comma syntax) */ diff --git a/composer.json b/composer.json index 8f3079d..0d3fc42 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^8.2", "ext-imagick": "*", + "ext-simplexml": "*", "ext-zip": "*", "bnussbau/laravel-trmnl-blade": "2.0.*", "bnussbau/trmnl-pipeline-php": "^0.3.0", diff --git a/composer.lock b/composer.lock index 97a6c15..44c00c7 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.356.43", + "version": "3.357.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "5722f6ea95240ef28f1ded35448bedcccb0b0883" + "reference": "0325c653b022aedb38f397cf559ab4d0f7967901" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5722f6ea95240ef28f1ded35448bedcccb0b0883", - "reference": "5722f6ea95240ef28f1ded35448bedcccb0b0883", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0325c653b022aedb38f397cf559ab4d0f7967901", + "reference": "0325c653b022aedb38f397cf559ab4d0f7967901", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.356.43" + "source": "https://github.com/aws/aws-sdk-php/tree/3.357.0" }, - "time": "2025-10-21T19:13:44+00:00" + "time": "2025-10-22T19:43:07+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -1618,16 +1618,16 @@ }, { "name": "laravel/framework", - "version": "v12.35.0", + "version": "v12.35.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef" + "reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef", - "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef", + "url": "https://api.github.com/repos/laravel/framework/zipball/d6d6e3cb68238e2fb25b440f222442adef5a8a15", + "reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15", "shasum": "" }, "require": { @@ -1833,7 +1833,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-10-21T15:15:41+00:00" + "time": "2025-10-23T15:25:03+00:00" }, { "name": "laravel/prompts", @@ -10471,16 +10471,16 @@ }, { "name": "rector/rector", - "version": "2.2.4", + "version": "2.2.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "904f12f23858ef54ec5782b05cb2979b703cb185" + "reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/904f12f23858ef54ec5782b05cb2979b703cb185", - "reference": "904f12f23858ef54ec5782b05cb2979b703cb185", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/fb9418af7777dfb1c87a536dc58398b5b07c74b9", + "reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9", "shasum": "" }, "require": { @@ -10519,7 +10519,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.4" + "source": "https://github.com/rectorphp/rector/tree/2.2.5" }, "funding": [ { @@ -10527,7 +10527,7 @@ "type": "github" } ], - "time": "2025-10-22T07:50:23+00:00" + "time": "2025-10-23T11:22:37+00:00" }, { "name": "sebastian/cli-parser", diff --git a/tests/Feature/PluginXmlResponseTest.php b/tests/Feature/PluginXmlResponseTest.php new file mode 100644 index 0000000..308d914 --- /dev/null +++ b/tests/Feature/PluginXmlResponseTest.php @@ -0,0 +1,171 @@ + Http::response([ + 'title' => 'Test Data', + 'items' => [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ], + ], 200, ['Content-Type' => 'application/json']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/api/data', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + + $plugin->refresh(); + + expect($plugin->data_payload)->toBe([ + 'title' => 'Test Data', + 'items' => [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ], + ]); +}); + +test('plugin parses XML responses and wraps under rss key', function (): void { + $xmlContent = ' + + + Test RSS Feed + + Test Item 1 + Description 1 + + + Test Item 2 + Description 2 + + + '; + + Http::fake([ + 'example.com/feed.xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/feed.xml', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + + $plugin->refresh(); + + expect($plugin->data_payload)->toHaveKey('rss'); + expect($plugin->data_payload['rss'])->toHaveKey('@attributes'); + expect($plugin->data_payload['rss'])->toHaveKey('channel'); + expect($plugin->data_payload['rss']['channel']['title'])->toBe('Test RSS Feed'); + expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2); +}); + +test('plugin handles non-XML content-type as JSON', function (): void { + $jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}'; + + Http::fake([ + 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/data', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + + $plugin->refresh(); + + expect($plugin->data_payload)->toBe([ + 'title' => 'Test Data', + 'items' => [1, 2, 3], + ]); +}); + +test('plugin handles invalid XML gracefully', function (): void { + $invalidXml = 'unclosed tag'; + + Http::fake([ + 'example.com/invalid.xml' => Http::response($invalidXml, 200, ['Content-Type' => 'application/xml']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/invalid.xml', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + + $plugin->refresh(); + + expect($plugin->data_payload)->toBe(['error' => 'Failed to parse XML response']); +}); + +test('plugin handles multiple URLs with mixed content types', function (): void { + $jsonResponse = ['title' => 'JSON Data', 'items' => [1, 2, 3]]; + $xmlContent = 'XML Data'; + + Http::fake([ + 'example.com/json' => Http::response($jsonResponse, 200, ['Content-Type' => 'application/json']), + 'example.com/xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => "https://example.com/json\nhttps://example.com/xml", + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + + $plugin->refresh(); + + expect($plugin->data_payload)->toHaveKey('IDX_0'); + expect($plugin->data_payload)->toHaveKey('IDX_1'); + + // First URL should be JSON + expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse); + + // Second URL should be XML wrapped under rss + expect($plugin->data_payload['IDX_1'])->toHaveKey('rss'); + expect($plugin->data_payload['IDX_1']['rss']['item'])->toBe('XML Data'); +}); + +test('plugin handles POST requests with XML responses', function (): void { + $xmlContent = 'successtest'; + + Http::fake([ + 'example.com/api' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/api', + 'polling_verb' => 'post', + 'polling_body' => '{"query": "test"}', + ]); + + $plugin->updateDataPayload(); + + $plugin->refresh(); + + expect($plugin->data_payload)->toHaveKey('rss'); + expect($plugin->data_payload['rss'])->toHaveKey('status'); + expect($plugin->data_payload['rss'])->toHaveKey('data'); + expect($plugin->data_payload['rss']['status'])->toBe('success'); + expect($plugin->data_payload['rss']['data'])->toBe('test'); +});