clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
langcode: en
status: true
dependencies:
enforced:
module:
- farm_sensor
id: sensor
label: Sensor
description: ''
workflow: asset_default
new_revision: true

View File

@@ -0,0 +1,11 @@
langcode: en
status: true
dependencies:
enforced:
module:
- farm_sensor
id: asset_sensor
color: grey
conditions:
asset_type:
- sensor

View File

@@ -0,0 +1,9 @@
name: Sensor asset
description: Adds a Sensor asset type with data streams.
type: module
package: farmOS Assets
core_version_requirement: ^10
dependencies:
- farm:asset
- farm:data_stream
- farm:farm_entity

View File

@@ -0,0 +1,99 @@
<?php
/**
* @file
* The farm_sensor module.
*/
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Url;
use Drupal\asset\Entity\AssetInterface;
use Drupal\data_stream\Entity\DataStreamInterface;
/**
* Implements hook_ENTITY_TYPE_view_alter().
*/
function farm_sensor_asset_view_alter(array &$build, AssetInterface $asset, EntityViewDisplayInterface $display) {
// Bail if this is not a sensor asset.
if ($asset->bundle() != 'sensor') {
return;
}
// If the user has permission to edit this sensor asset, display developer
// information.
// Only render developer information in the full view mode.
// Use getOriginalMode() because getMode() is not reliable. The default
// display mode will return "full" unless a "default" display is actually
// saved in config. In either case, the original mode is always "full".
if ($asset->access('update') === TRUE && $display->getOriginalMode() === 'full') {
// Add a Developer information details element with brief description.
$build['api'] = [
'#type' => 'details',
'#title' => t('Developer information'),
'#description' => t('This sensor asset will listen for data posted to it from other web-connected devices and save it to data streams based on the name of the value used in the request. If a data stream by that name does not exist, a new one will be created automatically. Data for multiple streams may be included in each request. Use the information below to configure your device to begin posting data to this sensor.'),
'#open' => FALSE,
];
// Build URL to the sensor API endpoint.
$url = new Url('farm_sensor.data_stream_data', ['uuid' => $asset->uuid()]);
// If the sensor is not public, include the private key.
if (!$asset->get('public')->value) {
$url->setOption('query', ['private_key' => $asset->get('private_key')->value]);
}
// Render the API url.
$url_string = $url->setAbsolute()->toString();
$url_string_label = t('URL');
$build['api']['url'] = [
'#type' => 'link',
'#title' => $url_string,
'#url' => $url,
'#prefix' => '<p><strong>' . $url_string_label . ':</strong> ',
'#suffix' => '</p>',
'#weight' => -10,
];
// Load referenced basic data streams.
$basic_data_streams = array_filter($asset->get('data_stream')->referencedEntities(), function (DataStreamInterface $data_stream) {
return $data_stream->bundle() === 'basic';
});
// Generate example stream names. If there are basic data streams already
// referenced then replace them with actual names.
$example_stream_names = [
'value',
'value2',
];
if (!empty($basic_data_streams)) {
foreach ($basic_data_streams as $key => $data_stream) {
if (!empty($data_stream->label())) {
$example_stream_names[$key] = Html::escape($data_stream->label());
}
}
}
// Render JSON examples.
$request_time = \Drupal::time()->getRequestTime();
$json_example = '{ "timestamp": ' . $request_time . ', "' . $example_stream_names[0] . '": 76.5 }';
$json_example_label = t('JSON example');
$build['api']['json_example'] = [
'#markup' => '<p><strong>' . $json_example_label . ':</strong> ' . $json_example . '</p>',
];
$json_example_multiple = '{ "timestamp": ' . $request_time . ', "' . $example_stream_names[0] . '": 76.5, "' . $example_stream_names[1] . '": 60 }';
$json_example_multiple_label = t('JSON example (multiple values)');
$build['api']['json_example_multiple'] = [
'#markup' => '<p><strong>' . $json_example_multiple_label . ':</strong> ' . $json_example_multiple . '</p>',
];
// Render example CURL command.
$curl_example = 'curl -H "Content-Type: application/json" -X POST -d \'' . $json_example . '\' ' . $url_string;
$curl_example_label = t('Example CURL command');
$build['api']['curl_example'] = [
'#markup' => '<p><strong>' . $curl_example_label . ':</strong> ' . $curl_example . '</p>',
];
}
}

View File

@@ -0,0 +1,8 @@
farm_sensor.data_stream_data:
path: '/asset/{uuid}/data/basic'
defaults:
_controller: '\Drupal\farm_sensor\Controller\SensorDataController::uuid'
requirements:
# There is no access restriction to this endpoint.
_access: 'TRUE'
methods: [GET, POST]

View File

@@ -0,0 +1,8 @@
name: Sensor listener
description: Provides a Listener (Legacy) API endpoint to support sensors created in farmOS 1.x.
type: module
package: farmOS (Legacy)
core_version_requirement: ^10
dependencies:
- farm:farm_sensor
- farm:data_stream

View File

@@ -0,0 +1,82 @@
<?php
/**
* @file
* The farm_sensor_listener module.
*/
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Url;
use Drupal\data_stream\Entity\DataStream;
use Drupal\data_stream\Entity\DataStreamInterface;
/**
* Implements hook_farm_entity_bundle_field_info().
*/
function farm_sensor_listener_farm_entity_bundle_field_info(EntityTypeInterface $entity_type, string $bundle) {
$fields = [];
// Add a public_key reference field to sensor assets.
if ($entity_type->id() === 'asset' && $bundle === 'sensor') {
$options = [
'type' => 'string',
'label' => t('Public key (legacy)'),
'description' => t('Public key (legacy) for the sensor.'),
'default_value_callback' => DataStream::class . '::createUniqueKey',
'weight' => [
'form' => 3,
],
];
$fields['public_key'] = \Drupal::service('farm_field.factory')->bundleFieldDefinition($options);
}
return $fields;
}
/**
* Implements hook_ENTITY_TYPE_view_alter().
*/
function farm_sensor_listener_asset_view_alter(array &$build, EntityInterface $asset, EntityViewDisplayInterface $display) {
// Bail if this is not a sensor asset.
if ($asset->bundle() != 'sensor') {
return;
}
// Only render developer information in the full view mode.
// Use getOriginalMode() because getMode() is not reliable. The default
// display mode will return "full" unless a "default" display is actually
// saved in config. In either case, the original mode is always "full".
if ($display->getOriginalMode() === 'full') {
// Bail if the sensor asset does not have any basic data streams.
$basic_data_streams = array_filter($asset->get('data_stream')->referencedEntities(), function (DataStreamInterface $data_stream) {
return $data_stream->bundle() === 'basic';
});
if (count($basic_data_streams) === 0) {
return;
}
// Build URL to the sensor Legacy API endpoint.
$url = new Url('farm_sensor_listener.data_stream', ['public_key' => $asset->get('public_key')->value]);
// If the sensor is not public, include the private key.
if (!$asset->get('public')->value) {
$url->setOption('query', ['private_key' => $asset->get('private_key')->value]);
}
// Render the legacy API URL.
$url_string = $url->setAbsolute()->toString();
$url_string_label = t('Legacy URL');
$build['api']['url_legacy'] = [
'#type' => 'link',
'#title' => $url_string,
'#url' => $url,
'#prefix' => '<p><strong>' . $url_string_label . ':</strong> ',
'#suffix' => '</p>',
'#weight' => -9,
];
}
}

View File

@@ -0,0 +1,8 @@
farm_sensor_listener.data_stream:
path: '/farm/sensor/listener/{public_key}'
defaults:
_controller: '\Drupal\farm_sensor_listener\Controller\SensorListenerController::publicKey'
requirements:
# There is no access restriction to this endpoint.
_access: 'TRUE'
methods: [GET, POST]

View File

@@ -0,0 +1,45 @@
<?php
namespace Drupal\farm_sensor_listener\Controller;
use Drupal\farm_sensor\Controller\SensorDataController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Route callbacks for the legacy sensor listener controller.
*/
class SensorListenerController extends SensorDataController {
/**
* Respond to GET or POST requests referencing sensor assets by public_key.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
* @param string $public_key
* The sensor asset public_key.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response.
*/
public function publicKey(Request $request, string $public_key) {
// Load the sensor asset.
$sensor_assets = $this->entityTypeManager()
->getStorage('asset')
->loadByProperties([
'type' => 'sensor',
'public_key' => $public_key,
]);
// Bail if the public_key is not found.
if (empty($sensor_assets)) {
throw new NotFoundHttpException();
}
/** @var \Drupal\asset\Entity\AssetInterface $asset */
$asset = reset($sensor_assets);
return $this->handleAssetRequest($asset, $request);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Drupal\Tests\farm_sensor_listener\Functional;
use Drupal\Tests\farm_sensor\Functional\SensorDataApiTest;
use Drupal\asset\Entity\AssetInterface;
/**
* Test the sensor listener (legacy) API.
*
* @group farm
*/
class SensorListenerApiTest extends SensorDataApiTest {
/**
* {@inheritdoc}
*/
protected static $modules = [
'farm_sensor_listener',
];
/**
* Helper function to build the path to the sensor API.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The asset.
*
* @return string
* The path.
*/
protected function buildPath(AssetInterface $asset) {
$public_key = $asset->get('public_key')->value;
return "base://farm/sensor/listener/{$public_key}";
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace Drupal\farm_sensor\Controller;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Controller\ControllerBase;
use Drupal\asset\Entity\AssetInterface;
use Drupal\data_stream\DataStreamTypeManager;
use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Handles requests for basic data streams associated with a sensor.
*/
class SensorDataController extends ControllerBase {
/**
* The basic data stream plugin.
*
* @var \Drupal\data_stream\Plugin\DataStream\DataStreamType\Basic
*/
protected $basicDataStream;
/**
* SensorDataController constructor.
*
* @param \Drupal\data_stream\DataStreamTypeManager $data_stream_type_manager
* The data stream type manager.
*/
public function __construct(DataStreamTypeManager $data_stream_type_manager) {
$this->basicDataStream = $data_stream_type_manager->createInstance('basic');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.data_stream_type')
);
}
/**
* Respond to GET or POST requests referencing sensor assets by UUID.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
* @param string $uuid
* The sensor asset UUID.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response.
*/
public function uuid(Request $request, string $uuid) {
// Load the sensor asset.
$sensor_assets = $this->entityTypeManager()
->getStorage('asset')
->loadByProperties([
'type' => 'sensor',
'uuid' => $uuid,
]);
// Bail if UUID is not found.
if (empty($sensor_assets)) {
throw new NotFoundHttpException();
}
/** @var \Drupal\asset\Entity\AssetInterface $asset */
$asset = reset($sensor_assets);
return $this->handleAssetRequest($asset, $request);
}
/**
* Helper function to handle the request once the asset has been loaded.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The asset.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response.
*/
protected function handleAssetRequest(AssetInterface $asset, Request $request) {
/** @var \Drupal\data_stream\Entity\DataStreamInterface[] $data_streams */
$data_streams = $asset->get('data_stream')->referencedEntities();
$basic_data_streams = array_filter($data_streams, function ($data_stream) {
return $data_stream->bundle() === 'basic';
});
// Get request method.
$method = $request->getMethod();
switch ($method) {
case Request::METHOD_GET:
// Bail if the sensor is not public and no private_key is provided.
if (!$asset->get('public')->value && !$this->requestHasValidPrivateKey($asset, $request)) {
throw new AccessDeniedHttpException();
}
$params = $request->query->all();
$max_limit = 100000;
$limit = $max_limit;
if (isset($params['limit'])) {
$limit = $params['limit'];
// Bail if more than the max is requested.
// Only allow 100k max data points to prevent exhausting PHP's memory,
// which is a potential DDoS vector.
if ($limit > $max_limit) {
throw new UnprocessableHttpEntityException();
}
}
$params['limit'] = $limit;
$data = $this->basicDataStream->storageGetMultiple($basic_data_streams, $params);
return new JsonResponse($data);
case Request::METHOD_POST:
// Bail if no private_key is provided.
if (!$this->requestHasValidPrivateKey($asset, $request)) {
throw new AccessDeniedHttpException();
}
// Load the data.
$data = Json::decode($request->getContent());
// Check for new named values.
$unique_names = $this->getUniqueNamedValues($data);
$existing_names = array_map(function ($data_stream) {
return $data_stream->label();
}, $basic_data_streams);
// Create new data streams for new named values.
foreach ($unique_names as $name) {
if (!in_array($name, $existing_names)) {
$basic_data_streams[] = $this->createDataStream($asset, $name);
}
}
// Allow each data stream to process the data.
foreach ($basic_data_streams as $data_stream) {
$this->basicDataStream->storageSave($data_stream, $data);
}
return new Response('', Response::HTTP_CREATED);
}
// Else raise error.
throw new MethodNotAllowedHttpException($this->basicDataStream->apiAllowedMethods());
}
/**
* Helper function to determine if the request provides a correct private_key.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The asset.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return bool
* If the request has access.
*/
protected function requestHasValidPrivateKey(AssetInterface $asset, Request $request) {
$private_key = $asset->get('private_key')->value;
return $private_key == $request->get('private_key', '');
}
/**
* Helper function to extract unique named values from the data payload.
*
* @param array $data
* The submitted data.
*
* @return array
* Array of unique names.
*/
protected function getUniqueNamedValues(array $data): array {
// Start an array of names.
$names = [];
// If the data is an array of multiple data points, iterate over each and
// recursively process.
if (is_array(reset($data))) {
foreach ($data as $point) {
$names = array_unique(array_merge($names, $this->getUniqueNamedValues($point)));
}
return $names;
}
// Iterate over the JSON properties to get each name.
foreach ($data as $key => $value) {
if ($key !== 'timestamp') {
$names[] = $key;
}
}
return array_unique($names);
}
/**
* Helper function to create a new basic data stream associated with a sensor.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The sensor asset.
* @param string $name
* The data stream name.
*
* @return \Drupal\Core\Entity\EntityInterface
* The new data stream.
*/
protected function createDataStream(AssetInterface $asset, string $name) {
// Create new data stream.
$new_data_stream = $this->entityTypeManager()->getStorage('data_stream')->create([
'type' => 'basic',
'name' => $name,
]);
$new_data_stream->save();
// Assign to the host sensor asset.
/** @var \Drupal\Core\Field\EntityReferenceFieldItemList $data_stream_field */
$data_stream_field = $asset->get('data_stream');
$data_stream_field->appendItem($new_data_stream);
$asset->save();
return $new_data_stream;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Drupal\farm_sensor\Plugin\Asset\AssetType;
use Drupal\data_stream\Entity\DataStream;
use Drupal\farm_entity\Plugin\Asset\AssetType\FarmAssetType;
/**
* Provides the sensor asset type.
*
* @AssetType(
* id = "sensor",
* label = @Translation("Sensor"),
* )
*/
class Sensor extends FarmAssetType {
/**
* {@inheritdoc}
*/
public function buildFieldDefinitions() {
$fields = parent::buildFieldDefinitions();
// Data stream field.
$options = [
'type' => 'entity_reference',
'label' => $this->t('Data stream'),
'description' => $this->t('Data streams provided by this sensor.'),
'target_type' => 'data_stream',
'multiple' => TRUE,
'weight' => [
'form' => 4,
'view' => 4,
],
];
$fields['data_stream'] = $this->farmFieldFactory->bundleFieldDefinition($options);
// Private key field.
$options = [
'type' => 'string',
'label' => $this->t('Private key'),
'description' => $this->t('Private key for the sensor.'),
'default_value_callback' => DataStream::class . '::createUniqueKey',
'weight' => [
'form' => 3,
],
'hidden' => 'view',
];
$fields['private_key'] = $this->farmFieldFactory->bundleFieldDefinition($options);
// Public field.
$options = [
'type' => 'boolean',
'label' => $this->t('Public'),
'description' => $this->t('Whether or not data from this sensor can be read publicly without the private key.'),
'default' => FALSE,
'weight' => [
'form' => 2,
'view' => 2,
],
];
$fields['public'] = $this->farmFieldFactory->bundleFieldDefinition($options);
return $fields;
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace Drupal\Tests\farm_sensor\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\Tests\farm_test\Functional\FarmBrowserTestBase;
use Drupal\asset\Entity\Asset;
use Drupal\asset\Entity\AssetInterface;
use GuzzleHttp\RequestOptions;
/**
* Test the Sensor data API.
*
* @group farm
*/
class SensorDataApiTest extends FarmBrowserTestBase {
/**
* The Sensor asset for testing.
*
* @var \Drupal\asset\Entity\AssetInterface
*/
protected $asset;
/**
* {@inheritdoc}
*/
protected static $modules = [
'farm_sensor',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->asset = Asset::create([
'type' => 'sensor',
'name' => $this->randomMachineName(),
]);
$this->asset->save();
}
/**
* Run all tests.
*/
public function testAll() {
$this->doTestApiGet();
$this->doTestApiPost();
}
/**
* Test API GET requests.
*/
public function doTestApiGet() {
// Build the path.
$uri = $this->buildPath($this->asset);
$url = Url::fromUri($uri);
// Build a private_key query param.
$private_key = [RequestOptions::QUERY => ['private_key' => $this->asset->get('private_key')->value]];
// Make a request.
$response = $this->processRequest('GET', $url);
// Assert that access is denied.
$this->assertEquals(403, $response->getStatusCode());
// Make a request with the private key.
$response = $this->processRequest('GET', $url, $private_key);
// Assert valid response.
$this->assertEquals(200, $response->getStatusCode());
$data = Json::decode($response->getBody());
$this->assertEquals(0, count($data));
// Make the sensor public.
$this->asset->set('public', TRUE)->save();
// Test that data can be accessed without the private key.
$response = $this->processRequest('GET', $url);
$this->assertEquals(200, $response->getStatusCode());
$data = Json::decode($response->getBody());
$this->assertEquals(0, count($data));
}
/**
* Test API POST requests.
*/
public function doTestApiPost() {
// Build the path.
$uri = $this->buildPath($this->asset);
$url = Url::fromUri($uri);
// Build a private_key query param.
$private_key = [RequestOptions::QUERY => ['private_key' => $this->asset->get('private_key')->value]];
// Make the asset public. This should not matter for posting data.
$this->asset->set('public', TRUE)->save();
// Test data.
$test_data = ['test_1' => 100, 'test_2' => 200];
// Make a request without a private key.
$payload = [RequestOptions::BODY => Json::encode($test_data)];
$response = $this->processRequest('POST', $url, $payload);
// Assert that access is denied.
$this->assertEquals(403, $response->getStatusCode());
// Post data with a private key.
$response = $this->processRequest('POST', $url, $private_key + $payload);
$this->assertEquals(201, $response->getStatusCode());
// Assert that new data streams were created.
$this->asset = Asset::load($this->asset->id());
$data_streams = $this->asset->get('data_stream')->referencedEntities();
$this->assertEquals(2, count($data_streams));
// Assert that new data was saved in DB.
$response = $this->processRequest('GET', $url);
$data = Json::decode($response->getBody());
$this->assertEquals(2, count($data));
// More test data.
$test_data = ['test_1' => 101, 'test_2' => 201];
// Post data with a private key.
$payload = [RequestOptions::BODY => Json::encode($test_data)];
$response = $this->processRequest('POST', $url, $private_key + $payload);
$this->assertEquals(201, $response->getStatusCode());
// Assert that no new data streams were created.
$this->asset = Asset::load($this->asset->id());
$data_streams = $this->asset->get('data_stream')->referencedEntities();
$this->assertEquals(2, count($data_streams));
// Assert that new data was saved in DB.
$response = $this->processRequest('GET', $url);
$data = Json::decode($response->getBody());
$this->assertEquals(4, count($data));
}
/**
* Helper function to build the path to the sensor API.
*
* @param \Drupal\asset\Entity\AssetInterface $asset
* The asset.
*
* @return string
* The path.
*/
protected function buildPath(AssetInterface $asset) {
return "base://asset/{$asset->uuid()}/data/basic";
}
/**
* Process a request.
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*
* @return \GuzzleHttp\Psr7\Response
* The response.
*
* @see \Drupal\Tests\jsonapi\Functional\JsonApiRequestTestTrait
*/
protected function processRequest(string $method, Url $url, array $request_options = []) {
$this->refreshVariables();
$request_options[RequestOptions::HTTP_ERRORS] = FALSE;
$client = $this->getSession()->getDriver()->getClient()->getClient();
return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
}
}