mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Compare commits
3 commits
5e0d0ad73f
...
4de32e9d47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de32e9d47 | ||
|
|
aa46dff00b | ||
|
|
311236a70d |
5 changed files with 250 additions and 30 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
[](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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use App\Liquid\Tags\TemplateTag;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
@ -22,6 +23,7 @@ use Illuminate\Support\Str;
|
||||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||||
|
use SimpleXMLElement;
|
||||||
|
|
||||||
class Plugin extends Model
|
class Plugin extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -83,7 +85,7 @@ class Plugin extends Model
|
||||||
$currentValue = $this->configuration[$fieldKey] ?? null;
|
$currentValue = $this->configuration[$fieldKey] ?? null;
|
||||||
|
|
||||||
// If the field has a default value and no current value is set, it's not missing
|
// 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
|
return true; // Found a required field that is not set and has no default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,11 +147,9 @@ class Plugin extends Model
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make the request based on the verb
|
// Make the request based on the verb
|
||||||
if ($this->polling_verb === 'post') {
|
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
||||||
$response = $httpRequest->post($resolvedUrl)->json();
|
|
||||||
} else {
|
$response = $this->parseResponse($httpResponse);
|
||||||
$response = $httpRequest->get($resolvedUrl)->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->update([
|
$this->update([
|
||||||
'data_payload' => $response,
|
'data_payload' => $response,
|
||||||
|
|
@ -183,14 +183,12 @@ class Plugin extends Model
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make the request based on the verb
|
// Make the request based on the verb
|
||||||
if ($this->polling_verb === 'post') {
|
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
||||||
$response = $httpRequest->post($resolvedUrl)->json();
|
|
||||||
} else {
|
$response = $this->parseResponse($httpResponse);
|
||||||
$response = $httpRequest->get($resolvedUrl)->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response is an array at root level
|
// 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
|
// Response is a sequential array, nest under .data
|
||||||
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||||
} else {
|
} 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)
|
* Apply Liquid template replacements (converts 'with' syntax to comma syntax)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"ext-imagick": "*",
|
"ext-imagick": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bnussbau/laravel-trmnl-blade": "2.0.*",
|
"bnussbau/laravel-trmnl-blade": "2.0.*",
|
||||||
"bnussbau/trmnl-pipeline-php": "^0.3.0",
|
"bnussbau/trmnl-pipeline-php": "^0.3.0",
|
||||||
|
|
|
||||||
34
composer.lock
generated
34
composer.lock
generated
|
|
@ -62,16 +62,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "aws/aws-sdk-php",
|
"name": "aws/aws-sdk-php",
|
||||||
"version": "3.356.43",
|
"version": "3.357.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||||
"reference": "5722f6ea95240ef28f1ded35448bedcccb0b0883"
|
"reference": "0325c653b022aedb38f397cf559ab4d0f7967901"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5722f6ea95240ef28f1ded35448bedcccb0b0883",
|
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0325c653b022aedb38f397cf559ab4d0f7967901",
|
||||||
"reference": "5722f6ea95240ef28f1ded35448bedcccb0b0883",
|
"reference": "0325c653b022aedb38f397cf559ab4d0f7967901",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -153,9 +153,9 @@
|
||||||
"support": {
|
"support": {
|
||||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
"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",
|
"name": "bnussbau/laravel-trmnl-blade",
|
||||||
|
|
@ -1618,16 +1618,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v12.35.0",
|
"version": "v12.35.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef"
|
"reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/d6d6e3cb68238e2fb25b440f222442adef5a8a15",
|
||||||
"reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef",
|
"reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -1833,7 +1833,7 @@
|
||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"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",
|
"name": "laravel/prompts",
|
||||||
|
|
@ -10471,16 +10471,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "rector/rector",
|
"name": "rector/rector",
|
||||||
"version": "2.2.4",
|
"version": "2.2.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/rectorphp/rector.git",
|
"url": "https://github.com/rectorphp/rector.git",
|
||||||
"reference": "904f12f23858ef54ec5782b05cb2979b703cb185"
|
"reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/904f12f23858ef54ec5782b05cb2979b703cb185",
|
"url": "https://api.github.com/repos/rectorphp/rector/zipball/fb9418af7777dfb1c87a536dc58398b5b07c74b9",
|
||||||
"reference": "904f12f23858ef54ec5782b05cb2979b703cb185",
|
"reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -10519,7 +10519,7 @@
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/rectorphp/rector/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -10527,7 +10527,7 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-10-22T07:50:23+00:00"
|
"time": "2025-10-23T11:22:37+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
|
|
|
||||||
171
tests/Feature/PluginXmlResponseTest.php
Normal file
171
tests/Feature/PluginXmlResponseTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
test('plugin parses JSON responses correctly', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'example.com/api/data' => 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 = '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Test RSS Feed</title>
|
||||||
|
<item>
|
||||||
|
<title>Test Item 1</title>
|
||||||
|
<description>Description 1</description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Test Item 2</title>
|
||||||
|
<description>Description 2</description>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>';
|
||||||
|
|
||||||
|
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 = '<root><item>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 = '<root><item>XML Data</item></root>';
|
||||||
|
|
||||||
|
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 = '<response><status>success</status><data>test</data></response>';
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue