Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
/*.phar
|
||||
/*.yaml
|
||||
13
Makefile
Normal file
13
Makefile
Normal file
@@ -0,0 +1,13 @@
|
||||
.PHONY: help all phar
|
||||
|
||||
help:
|
||||
@echo "\e[1mTargets:\e[0m"
|
||||
@echo " \e[3mall\e[0m — build everything\n \e[3mphar\e[0m — build the phar"
|
||||
|
||||
all: phar
|
||||
|
||||
phar:
|
||||
box compile
|
||||
mv bin/slotdbd.phar ./slotdbd.phar
|
||||
|
||||
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# SlotDB
|
||||
|
||||
SlotDB is an application designed to store information about property or assets, to
|
||||
be able to query on that information, and implement basic schemas.
|
||||
|
||||
## Installing
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
$ docker run -it \
|
||||
-v $PWD/slotdb.json:/app/slotdb.json \
|
||||
dev.noccylabs.info/slotdb/slotdb
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
## API
|
||||
|
||||
### /api/slotdb/v1/slots [GET]
|
||||
|
||||
**Get a list of all slots, or slots matching conditions**
|
||||
|
||||
```json
|
||||
GET /api/slotdb/v1/slots
|
||||
|
||||
HTTP 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slots": [
|
||||
{
|
||||
"id": "1.1",
|
||||
"name": "First thing",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
GET /api/slotdb/v1/slots?k=id&available.eq=true
|
||||
|
||||
HTTP 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slots": [
|
||||
{
|
||||
"id": "1.1"
|
||||
},
|
||||
{
|
||||
"id": "1.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Params | Value
|
||||
| ------------- | --------
|
||||
| p | Page
|
||||
| k | Comma-separated list of keys to return
|
||||
| {key}.{cond} | Match condition
|
||||
|
||||
### /api/slotdb/v1/slots [POST]
|
||||
|
||||
**Create one or more new slots**
|
||||
|
||||
### /api/slotdb/v1/slot/{slot} [GET]
|
||||
|
||||
### /api/slotdb/v1/slot/{slot} [PUT]
|
||||
|
||||
### /api/slotdb/v1/slot/{slot} [DELETE]
|
||||
4
bin/slotdbd
Executable file
4
bin/slotdbd
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require_once __DIR__."/../src/bootstrap.php";
|
||||
30
composer.json
Normal file
30
composer.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "slotdb/slotdb",
|
||||
"description": "A key-value store for fixed inventory or assets",
|
||||
"type": "application",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SlotDb\\SlotDb\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christopher Vagnetoft",
|
||||
"email": "labs@noccy.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"react/react": "^1.4",
|
||||
"noccylabs/react-http": "^0.2.5",
|
||||
"doctrine/dbal": "^4.2",
|
||||
"doctrine/orm": "^3.3",
|
||||
"symfony/cache": "^7.2",
|
||||
"psr/log": "^3.0",
|
||||
"monolog/monolog": "^3.8",
|
||||
"scienta/doctrine-json-functions": "^6.3"
|
||||
},
|
||||
"bin": [
|
||||
"bin/slotdbd"
|
||||
]
|
||||
}
|
||||
3028
composer.lock
generated
Normal file
3028
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
110
doc/slotdb.openapi.yaml
Normal file
110
doc/slotdb.openapi.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
openapi: '3.0.3'
|
||||
info:
|
||||
title: SlotDB
|
||||
version: '1.0'
|
||||
servers:
|
||||
- url: http://127.0.0.1:8080/api/slotdb/v1
|
||||
paths:
|
||||
/slots:
|
||||
get:
|
||||
description: Query all slots
|
||||
tags: [ Slots ]
|
||||
parameters:
|
||||
- in: query
|
||||
name: view
|
||||
description: Comma-separated list of keys to return
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: group
|
||||
description: Comma-separated list of groups to filter on
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: limit
|
||||
description: Number of results per page
|
||||
schema: { type: number }
|
||||
- in: query
|
||||
name: page
|
||||
description: Page number (from 0)
|
||||
schema: { type: number }
|
||||
- in: query
|
||||
name: match
|
||||
description: Match expression for returned results
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
description: Create or update slots
|
||||
tags: [ Slots ]
|
||||
requestBody:
|
||||
description: Array of slots
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [ id ]
|
||||
properties:
|
||||
id: { type: string, description: "Unique slot ID", example: "4B-1" }
|
||||
name: { type: string, description: "Like ID, but for humans", example: "4B Unit 1" }
|
||||
properties: { type: object, additionalProperties: true, example: { is-available: true, monthly-rate: 191 } }
|
||||
group: { type: string, description: "The group that this slot belongs to", example: "units" }
|
||||
responses:
|
||||
'201':
|
||||
description: Slots created
|
||||
'409':
|
||||
description: Slot already exists
|
||||
'422':
|
||||
description: Unprocessable content
|
||||
/slot/{slot}:
|
||||
get:
|
||||
tags: [ Slots ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
tags: [ Slots ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
delete:
|
||||
tags: [ Slots ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/slot/{slot}/{key}:
|
||||
get:
|
||||
description: Query a specific property from a slot
|
||||
tags: [ Slots ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/groups:
|
||||
get:
|
||||
tags: [ Groups ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
tags: [ Groups ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/group/{group}:
|
||||
get:
|
||||
tags: [ Groups ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
tags: [ Groups ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
delete:
|
||||
tags: [ Groups ]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
14
src/Bus/Message.php
Normal file
14
src/Bus/Message.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Bus;
|
||||
|
||||
class Message
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $message,
|
||||
public readonly array $data,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
38
src/Bus/MessageBus.php
Normal file
38
src/Bus/MessageBus.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Bus;
|
||||
|
||||
class MessageBus
|
||||
{
|
||||
private array $subscribers = [];
|
||||
|
||||
private array $queue = [];
|
||||
|
||||
public function onMessage(string $message, callable $handler) {
|
||||
if (!isset($this->subscribers[$message])) {
|
||||
$this->subscribers[$message] = [];
|
||||
}
|
||||
$this->subscribers[$message][] = $handler;
|
||||
}
|
||||
|
||||
public function pushMessage(Message $message): void
|
||||
{
|
||||
$this->queue[] = $message;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
while ($message = array_shift($this->queue)) {
|
||||
if (isset($this->subscribers[$message->message])) {
|
||||
foreach ($this->subscribers[$message->message] as $handler) {
|
||||
call_user_func($handler, $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function push(string $message, array $data): void
|
||||
{
|
||||
$this->pushMessage(new Message($message, $data));
|
||||
}
|
||||
}
|
||||
79
src/Daemon.php
Normal file
79
src/Daemon.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb;
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use NoccyLabs\React\Http\Handler\RequestHandler;
|
||||
use NoccyLabs\React\Http\Middleware\RoutingMiddleware;
|
||||
use NoccyLabs\React\Http\Routing\RouteCollection;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use React\Http\HttpServer;
|
||||
use React\Http\Message\Response;
|
||||
use React\Socket\SocketServer;
|
||||
use SlotDb\SlotDb\Bus\MessageBus;
|
||||
use SlotDb\SlotDb\Data\Database;
|
||||
use SlotDb\SlotDb\Data\Group\Group;
|
||||
use SlotDb\SlotDb\Data\Slot\Slot;
|
||||
use SlotDb\SlotDb\Data\Slot\SlotRepository;
|
||||
|
||||
class Daemon
|
||||
{
|
||||
|
||||
private string $dataDir = "./var";
|
||||
|
||||
private Database $database;
|
||||
|
||||
private HttpServer $server;
|
||||
|
||||
private LoggerInterface $logger;
|
||||
|
||||
private MessageBus $messageBus;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $listen = '0.0.0.0:4949',
|
||||
?string $dataDir = null,
|
||||
?LoggerInterface $logger = null,
|
||||
)
|
||||
{
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
|
||||
$this->dataDir = $dataDir ?? $this->dataDir;
|
||||
|
||||
$upgradeDb = !file_exists($this->dataDir."/slots.db");
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => $this->dataDir."/slots.db",
|
||||
]);
|
||||
|
||||
$this->database = new Database($conn, $logger);
|
||||
$this->database->updateSchema($upgradeDb);
|
||||
$em = $this->database->getEntityManager();
|
||||
|
||||
$this->messageBus = new MessageBus();
|
||||
|
||||
$routes = new RouteCollection();
|
||||
$routes->addController(new Http\Controller\SlotsController($em->getRepository(Slot::class), $this->messageBus));
|
||||
$routes->addController(new Http\Controller\GroupsController($em->getRepository(Group::class)));
|
||||
|
||||
$this->server = new HttpServer(
|
||||
new Http\Middleware\LoggingMiddleware(),
|
||||
new RoutingMiddleware($routes),
|
||||
new RequestHandler(),
|
||||
function (ServerRequestInterface $request) {
|
||||
return Response::plaintext("Not found")->withStatus(404);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->logger->info("Starting up...");
|
||||
$this->server->listen(new SocketServer($this->listen));
|
||||
$this->logger->debug(sprintf("Listening for HTTP connections on %s", $this->listen));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
61
src/Data/Database.php
Normal file
61
src/Data/Database.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Types\SimpleArrayType;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Mapping\ClassMetadataFactory;
|
||||
use Doctrine\ORM\ORMSetup;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Scienta\DoctrineJsonFunctions\Query\AST\Functions\Sqlite as DqlFunctions;
|
||||
|
||||
class Database
|
||||
{
|
||||
private ?EntityManager $em = null;
|
||||
|
||||
public function __construct(private Connection $connection, private LoggerInterface $logger)
|
||||
{
|
||||
$config = ORMSetup::createAttributeMetadataConfiguration([
|
||||
dirname(__DIR__)
|
||||
], true, null, null, false);
|
||||
|
||||
$config->addCustomStringFunction(DqlFunctions\JsonExtract::FUNCTION_NAME, DqlFunctions\JsonExtract::class);
|
||||
// $config->addCustomStringFunction(DqlFunctions\JsonSearch::FUNCTION_NAME, DqlFunctions\JsonSearch::class);
|
||||
|
||||
$this->em = new EntityManager($connection, $config);
|
||||
}
|
||||
|
||||
public function getEntityManager(): EntityManager
|
||||
{
|
||||
return $this->em;
|
||||
}
|
||||
|
||||
public function updateSchema(bool $create = false): void
|
||||
{
|
||||
|
||||
$tool = new SchemaTool($this->em);
|
||||
|
||||
$cmf = new ClassMetadataFactory();
|
||||
$cmf->setEntityManager($this->em);
|
||||
$metadata = $cmf->getAllMetadata();
|
||||
|
||||
if ($create) {
|
||||
$queries = $tool->getCreateSchemaSql($metadata);
|
||||
} else {
|
||||
$queries = $tool->getUpdateSchemaSql($metadata);
|
||||
}
|
||||
|
||||
if (count($queries) > 0) {
|
||||
$this->logger->info(sprintf("%s database schema...", $create?"Creating":"Upgrading"));
|
||||
} else {
|
||||
$this->logger->debug("Database schema is up-to-date");
|
||||
}
|
||||
for($i=0; $i<count($queries);$i++){
|
||||
$this->logger->debug(sprintf("-- %s", $queries[$i]));
|
||||
$this->em->getConnection()->prepare($queries[$i])->executeQuery();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
57
src/Data/Group/Group.php
Normal file
57
src/Data/Group/Group.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Group;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use ArrayIterator;
|
||||
use IteratorAggregate;
|
||||
use JsonSerializable;
|
||||
use Traversable;
|
||||
|
||||
#[ORM\Table(name: 'groups')]
|
||||
#[ORM\Entity(repositoryClass: GroupRepository::class)]
|
||||
class Group implements IteratorAggregate, JsonSerializable
|
||||
{
|
||||
|
||||
#[ORM\Id()]
|
||||
#[ORM\GeneratedValue()]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 96, unique: true)]
|
||||
private string $groupId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 96)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'simple_array')]
|
||||
private array $props = [];
|
||||
|
||||
public function __construct(
|
||||
string $groupId,
|
||||
string $name,
|
||||
)
|
||||
{
|
||||
$this->groupId = $groupId;
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->props);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'props' => (object)$this->props,
|
||||
];
|
||||
}
|
||||
|
||||
public function getChanges(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
28
src/Data/Group/GroupRepository.php
Normal file
28
src/Data/Group/GroupRepository.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Group;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class GroupRepository extends EntityRepository
|
||||
{
|
||||
|
||||
public function createGroup(string $id, ?string $name): Group
|
||||
{
|
||||
return new Group(
|
||||
groupId: $id,
|
||||
name: $name??$id,
|
||||
);
|
||||
}
|
||||
|
||||
public function findGroup(string $id): Group
|
||||
{
|
||||
$slot = $this->findOneBy([ 'groupId' => $id ]);
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function findGroups(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
24
src/Data/Hook/Action/RestAction.php
Normal file
24
src/Data/Hook/Action/RestAction.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Hook\Action;
|
||||
|
||||
// --8<-- Action.php
|
||||
|
||||
abstract class Action
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
// --8<-- RestAction.php
|
||||
|
||||
class RestAction extends Action
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $url,
|
||||
public readonly string $method,
|
||||
public readonly ?string $template,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
19
src/Data/Hook/Hook.php
Normal file
19
src/Data/Hook/Hook.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Hook;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Table(name:"hooks")]
|
||||
#[ORM\Entity()]
|
||||
class Hook
|
||||
{
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: "integer")]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: "string", length: 64, unique: true)]
|
||||
private string $name;
|
||||
}
|
||||
29
src/Data/Hook/Trigger/PropertyTrigger.php
Normal file
29
src/Data/Hook/Trigger/PropertyTrigger.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Hook\Trigger;
|
||||
|
||||
use SlotDb\SlotDb\Bus\Message;
|
||||
use SlotDb\SlotDb\Bus\MessageBus;
|
||||
|
||||
class PropertyTrigger extends Trigger
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
public readonly string $property,
|
||||
public readonly ?string $condition,
|
||||
public readonly mixed $compare,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
public function connect(MessageBus $bus): void
|
||||
{
|
||||
$bus->onMessage('property.change', function (Message $message) {
|
||||
// TODO check if $this->property matches $message->data->property
|
||||
// TODO emit triggered event
|
||||
$this->emit(self::EVT_TRIGGERED, [ ]);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/Data/Hook/Trigger/Trigger.php
Normal file
18
src/Data/Hook/Trigger/Trigger.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Hook\Trigger;
|
||||
|
||||
use Evenement\EventEmitterInterface;
|
||||
use Evenement\EventEmitterTrait;
|
||||
use SlotDb\SlotDb\Bus\Message;
|
||||
use SlotDb\SlotDb\Bus\MessageBus;
|
||||
|
||||
abstract class Trigger implements EventEmitterInterface
|
||||
{
|
||||
use EventEmitterTrait;
|
||||
|
||||
const string EVT_TRIGGERED = 'triggered';
|
||||
|
||||
|
||||
abstract public function connect(MessageBus $bus): void;
|
||||
}
|
||||
27
src/Data/Hook/Trigger/TriggerFactory.php
Normal file
27
src/Data/Hook/Trigger/TriggerFactory.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Hook\Trigger;
|
||||
|
||||
use Evenement\EventEmitterInterface;
|
||||
use Evenement\EventEmitterTrait;
|
||||
use SlotDb\SlotDb\Bus\Message;
|
||||
use SlotDb\SlotDb\Bus\MessageBus;
|
||||
|
||||
class TriggerFactory
|
||||
{
|
||||
private array $triggers = [];
|
||||
|
||||
public function createTrigger(string $type, array $options): Trigger
|
||||
{
|
||||
$hash = hash('sha256', json_encode([ $type, $options ]));
|
||||
if (!isset($this->triggers[$hash])) {
|
||||
$this->triggers[$hash] = match ($type) {
|
||||
'property' => new PropertyTrigger(...$options),
|
||||
default => throw new \Exception("InvalidTriggerException"),
|
||||
};
|
||||
}
|
||||
return $this->triggers[$hash];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
15
src/Data/Schema/Schema.php
Normal file
15
src/Data/Schema/Schema.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Schema;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Table(name: 'schemas')]
|
||||
#[ORM\Entity()]
|
||||
class Schema
|
||||
{
|
||||
#[ORM\Id()]
|
||||
#[ORM\GeneratedValue()]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
}
|
||||
58
src/Data/Slot/Slot.php
Normal file
58
src/Data/Slot/Slot.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Slot;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use ArrayIterator;
|
||||
use IteratorAggregate;
|
||||
use JsonSerializable;
|
||||
use Traversable;
|
||||
|
||||
#[ORM\Table(name: 'slots')]
|
||||
#[ORM\Entity(repositoryClass: SlotRepository::class)]
|
||||
class Slot implements IteratorAggregate, JsonSerializable
|
||||
{
|
||||
|
||||
#[ORM\Id()]
|
||||
#[ORM\GeneratedValue()]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 96, unique: true)]
|
||||
private string $slotId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 96)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private array $props = [];
|
||||
|
||||
public function __construct(
|
||||
string $slotId,
|
||||
string $name,
|
||||
)
|
||||
{
|
||||
$this->slotId = $slotId;
|
||||
$this->name = $name;
|
||||
$this->props = [];
|
||||
}
|
||||
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return new ArrayIterator($this->props);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'props' => (object)$this->props,
|
||||
];
|
||||
}
|
||||
|
||||
public function getChanges(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
64
src/Data/Slot/SlotRepository.php
Normal file
64
src/Data/Slot/SlotRepository.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Data\Slot;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class SlotRepository extends EntityRepository
|
||||
{
|
||||
|
||||
public function createSlot(string $id, ?string $name): Slot
|
||||
{
|
||||
$slot = new Slot(
|
||||
slotId: $id,
|
||||
name: $name??$id,
|
||||
);
|
||||
$this->getEntityManager()->persist($slot);
|
||||
$this->getEntityManager()->flush();
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function findSlot(string $id): Slot
|
||||
{
|
||||
$slot = $this->findOneBy([ 'slotId' => $id ]);
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function findSlots(): array
|
||||
{
|
||||
return $this->findAll();
|
||||
}
|
||||
|
||||
public function findSlotsByProps(array $props): array
|
||||
{
|
||||
$builder = $this->createQueryBuilder('s');
|
||||
|
||||
$v = 0;
|
||||
foreach ($props as $prop => $value) {
|
||||
$exprid = "expr".($v);
|
||||
$valid = "val".($v++);
|
||||
if (is_array($value)) {
|
||||
$test = array_shift($value);
|
||||
$value = array_shift($value);
|
||||
} else {
|
||||
$test = 'eq';
|
||||
}
|
||||
$cond = match ($test) {
|
||||
'eq' => '=',
|
||||
'neq' => '!=',
|
||||
'gt' => '>',
|
||||
'gte' => '>=',
|
||||
'lt' => '<',
|
||||
'lte' => '<='
|
||||
};
|
||||
$match = 'json_extract(s.props,:'.$exprid.')';
|
||||
$builder->andWhere("{$match} {$cond} :{$valid}")
|
||||
->setParameter($exprid, '$.'.$prop)
|
||||
->setParameter($valid, $value);
|
||||
}
|
||||
|
||||
$res = $builder->getQuery()->getResult();
|
||||
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
31
src/Http/Attribute/Route.php
Normal file
31
src/Http/Attribute/Route.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Attribute;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_METHOD)]
|
||||
class Route
|
||||
{
|
||||
public readonly string $pattern;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $path,
|
||||
public readonly array $methods,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function matches(ServerRequestInterface $request): bool
|
||||
{
|
||||
if (!in_array($request->getMethod(), $this->methods)) {
|
||||
return false;
|
||||
}
|
||||
if (!preg_match($this->pattern, $request->getUri()->getPath(), $matches)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
51
src/Http/Controller/Controller.php
Normal file
51
src/Http/Controller/Controller.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Controller;
|
||||
|
||||
use Reflection;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use Reflector;
|
||||
use SlotDb\SlotDb\Http\Attribute\Route;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
|
||||
|
||||
public function getRoutes(): array
|
||||
{
|
||||
$routes = [];
|
||||
$rc = new ReflectionClass($this);
|
||||
foreach ($rc->getMethods() as $method) {
|
||||
$route = $this->getAttribute($method, Route::class);
|
||||
if ($route) $routes[] = new class($route, $method->getClosure($this)) {
|
||||
private $handler;
|
||||
public function __construct(
|
||||
public readonly Route $route,
|
||||
callable $handler
|
||||
)
|
||||
{
|
||||
$this->handler = $handler;
|
||||
}
|
||||
public function getPattern(): string
|
||||
{
|
||||
return ".+";
|
||||
}
|
||||
public function __invoke(...$args)
|
||||
{
|
||||
return call_user_func_array($this->handler, $args);
|
||||
}
|
||||
};
|
||||
}
|
||||
return $routes;
|
||||
}
|
||||
|
||||
private function getAttribute(ReflectionMethod $reflector, string $attribute): mixed
|
||||
{
|
||||
$attrs = $reflector->getAttributes($attribute);
|
||||
if (empty($attrs)) return null;
|
||||
$attr = reset($attrs);
|
||||
return $attr->newInstance();
|
||||
}
|
||||
|
||||
}
|
||||
61
src/Http/Controller/GroupsController.php
Normal file
61
src/Http/Controller/GroupsController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Controller;
|
||||
|
||||
use NoccyLabs\React\Http\Attributes\Route;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\Http\Message\Response;
|
||||
use SlotDb\SlotDb\Data\Group\Group;
|
||||
use SlotDb\SlotDb\Data\Group\GroupRepository;
|
||||
|
||||
|
||||
class GroupsController extends Controller
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly GroupRepository $groups
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/groups", methods:["GET"])]
|
||||
public function findGroups(ServerRequestInterface $request)
|
||||
{
|
||||
$slots = $this->groups->findSlots();
|
||||
return Response::json($slots);
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/groups", methods:["POST"])]
|
||||
public function createGroups(ServerRequestInterface $request)
|
||||
{
|
||||
$data = json_decode($request->getBody());
|
||||
$groups = [];
|
||||
foreach ($data as $item) {
|
||||
$groupId = $item->id;
|
||||
$groupName = $item->name??null;
|
||||
$groups[] = $this->groups->createSlot($groupId, $groupName);
|
||||
}
|
||||
|
||||
return Response::json($groups);
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/group/{group}", methods:["GET"])]
|
||||
public function queryGroup(ServerRequestInterface $request, string $group)
|
||||
{
|
||||
return Response::json(true);
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/group/{group}", methods:["POST"])]
|
||||
public function updateGroup(ServerRequestInterface $request, string $group)
|
||||
{
|
||||
$data = $this->groups->findGroup($group);
|
||||
|
||||
$posted = json_decode($request->getBody());
|
||||
foreach ((array)$posted as $prop=>$op) {
|
||||
|
||||
}
|
||||
|
||||
return Response::json(true);
|
||||
}
|
||||
}
|
||||
80
src/Http/Controller/SlotsController.php
Normal file
80
src/Http/Controller/SlotsController.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Controller;
|
||||
|
||||
use NoccyLabs\React\Http\Attributes\Route;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\Http\Message\Response;
|
||||
use SlotDb\SlotDb\Bus\MessageBus;
|
||||
use SlotDb\SlotDb\Data\Slot\Slot;
|
||||
use SlotDb\SlotDb\Data\Slot\SlotRepository;
|
||||
|
||||
|
||||
class SlotsController extends Controller
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly SlotRepository $slots,
|
||||
private readonly MessageBus $bus,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/slots", methods:["GET"])]
|
||||
public function findSlots(ServerRequestInterface $request)
|
||||
{
|
||||
$wheres = (array)$request->getQueryParams()['where']??[];
|
||||
if (count($wheres) > 0) {
|
||||
$props = [];
|
||||
foreach ($wheres as $where) {
|
||||
[$k,$v] = explode(":", $where, 2);
|
||||
$props[$k] = json_decode($v) ?? $v;
|
||||
}
|
||||
$slots = $this->slots->findSlotsByProps($props);
|
||||
} else {
|
||||
$slots = $this->slots->findSlots();
|
||||
}
|
||||
return Response::json($slots);
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/slots", methods:["POST"])]
|
||||
public function createSlots(ServerRequestInterface $request)
|
||||
{
|
||||
$data = json_decode($request->getBody());
|
||||
$slots = [];
|
||||
foreach ($data as $item) {
|
||||
$slotId = $item->id;
|
||||
$slotName = $item->name??null;
|
||||
try {
|
||||
$slot = $this->slots->createSlot($slotId, $slotName);
|
||||
$slots[] = $slot;
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
return Response::plaintext((string)$e)->withStatus(500);
|
||||
}
|
||||
}
|
||||
|
||||
return Response::json($slots);
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/slot/{slot}", methods:["GET"])]
|
||||
public function querySlot(ServerRequestInterface $request, string $slot)
|
||||
{
|
||||
return Response::json(true);
|
||||
}
|
||||
|
||||
#[Route(path:"/api/slotdb/v1/slot/{slot}", methods:["POST"])]
|
||||
public function updateSlot(ServerRequestInterface $request, string $slot)
|
||||
{
|
||||
$data = $this->slots->findSlot($slot);
|
||||
|
||||
$posted = json_decode($request->getBody());
|
||||
foreach ((array)$posted as $prop=>$op) {
|
||||
|
||||
}
|
||||
|
||||
return Response::json(true);
|
||||
}
|
||||
}
|
||||
35
src/Http/Middleware/LoggingMiddleware.php
Normal file
35
src/Http/Middleware/LoggingMiddleware.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\Http\Message\Response;
|
||||
use React\Promise\PromiseInterface;
|
||||
|
||||
class LoggingMiddleware
|
||||
{
|
||||
public function __invoke(ServerRequestInterface $request, ?callable $next = null)
|
||||
{
|
||||
try {
|
||||
$response = $next($request);
|
||||
if ($response instanceof PromiseInterface) {
|
||||
$response->then(function ($response) use ($request) {
|
||||
$this->logRequest($request, $response);
|
||||
});
|
||||
} else {
|
||||
$this->logRequest($request, $response);
|
||||
}
|
||||
return $response;
|
||||
} catch (\Throwable $t) {
|
||||
fwrite(STDERR, $t."\n");
|
||||
return Response::plaintext($t)->withStatus(500);
|
||||
}
|
||||
}
|
||||
|
||||
private function logRequest(ServerRequestInterface $request, ResponseInterface $response): void
|
||||
{
|
||||
fprintf(STDERR, "%s [%s] %d\n", $request->getMethod(), $request->getUri(), $response->getStatusCode());
|
||||
}
|
||||
|
||||
}
|
||||
41
src/Http/Middleware/RoutingMiddleware.php
Normal file
41
src/Http/Middleware/RoutingMiddleware.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\Http\Message\Response;
|
||||
use React\Promise\PromiseInterface;
|
||||
|
||||
class RoutingMiddleware
|
||||
{
|
||||
|
||||
private array $routes = [];
|
||||
|
||||
private array $controllers = [];
|
||||
|
||||
public function __construct(
|
||||
array $controllers
|
||||
)
|
||||
{
|
||||
foreach ($controllers as $controller) {
|
||||
$this->controllers[get_class($controller)] = $controller;
|
||||
$routes = $controller->getRoutes();
|
||||
foreach ($routes as $route) {
|
||||
$this->routes[$route->getPattern()] = $route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(ServerRequestInterface $request, ?callable $next = null)
|
||||
{
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route->route->matches($request)) {
|
||||
return $route($request);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
}
|
||||
19
src/Http/Middleware/SecurityMiddleware.php
Normal file
19
src/Http/Middleware/SecurityMiddleware.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SlotDb\SlotDb\Http\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\Http\Message\Response;
|
||||
use React\Promise\PromiseInterface;
|
||||
|
||||
class SecurityMiddleware
|
||||
{
|
||||
public function __invoke(ServerRequestInterface $request, ?callable $next = null)
|
||||
{
|
||||
$response = $next($request);
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
81
src/bootstrap.php
Normal file
81
src/bootstrap.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__."/../vendor/autoload.php";
|
||||
|
||||
use SlotDb\SlotDb\Daemon;
|
||||
|
||||
$opts = getopt("hl:", [
|
||||
"help", "listen:", "config:",
|
||||
"db:", "init-db", "upgrade-db",
|
||||
"root:", "cert:", "key:",
|
||||
"jwt-secret:", "jwt-claim:"
|
||||
]);
|
||||
|
||||
function print_usage(): never {
|
||||
echo <<<HELP
|
||||
Usage:
|
||||
slotdbd [options]
|
||||
|
||||
Options:
|
||||
-h,--help Show this help
|
||||
-l,--listen ADDR Set listen address for HTTP requests (env: SLOTDB_LISTEN)
|
||||
-c,--config FILE Read configuration from file (env: SLOTDB_CONFIG)
|
||||
--db URI Use custom database connection URI (env: SLOTDB_DATABASE)
|
||||
--init-db Initialize database schema
|
||||
--upgrade-db Upgrade database schema
|
||||
--root FILE Set root CA certificate for SSL (env: SLOTDB_SSL_ROOT)
|
||||
--cert FILE Set certificate for SSL (env: SLOTDB_SSL_CERT)
|
||||
--key FILE Set private key file for SSL (env: SLOTDB_SSL_KEY)
|
||||
--jwt-secret SECRET Use JWT auth with secret (env: SLOTDB_JWT_SECRET)
|
||||
--jwt-claim CLAIM Claim key to use for matching properties (env:SLOTDB_JWT_CLAIM)
|
||||
|
||||
Defaults:
|
||||
HTTP listen address: 0.0.0.0:6680
|
||||
Certificates/key: none
|
||||
JWT auth: none
|
||||
|
||||
JWT Claims:
|
||||
The key pointed to by the claim option should be a string or an array of
|
||||
strings, with each string granting access to a group, slot or property.
|
||||
|
||||
"*#*:*/r" Read all groups, all slots, all properties
|
||||
"first#/rw" Read-write everything in the group first
|
||||
"slot*/rw" Read-write all slots starting with 'slot' in all groups
|
||||
"slota,slotb/w" Write-only access to slots 'slota' and 'slotb'
|
||||
"*:available" Read-only access to the 'available' prop on all slots
|
||||
|
||||
Examples:
|
||||
SLOTDB_LISTEN=0.0.0.0:9876 slotdb
|
||||
slotdb -l 0.0.0.0:9876
|
||||
Start daemon and listen on 0.0.0.0:9876
|
||||
SLOTDB_LISTEN=0.0.0.0:9876 slotdb -c slotdb.yaml
|
||||
Read config from slotdb.yaml, and then set listen address
|
||||
|
||||
HELP;
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (isset($opts['h']) || isset($opts['help'])) {
|
||||
print_usage();
|
||||
}
|
||||
|
||||
if (isset($opts['db'])) {
|
||||
$initDb = isset($opts['init-db']);
|
||||
$upgradeDb = isset($opts['upgrade-db']);
|
||||
}
|
||||
|
||||
$listen = getenv("SLOTDB_LISTEN")
|
||||
?: ($opts['listen']
|
||||
?? ($opts['l'] ?? null));
|
||||
|
||||
$logHandlers = [
|
||||
new Monolog\Handler\StreamHandler(STDOUT),
|
||||
];
|
||||
$logger = new Monolog\Logger("main", $logHandlers);
|
||||
|
||||
$daemon = new Daemon(
|
||||
listen: $listen ?? '0.0.0.0:8080',
|
||||
logger: $logger,
|
||||
);
|
||||
|
||||
$daemon->start();
|
||||
2
var/.gitignore
vendored
Normal file
2
var/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/*.db
|
||||
/*/*.json
|
||||
Reference in New Issue
Block a user