- Added a new ArrayCollection

- Changed Action attribute and transformed it into an abstract class
This commit is contained in:
Dave M. 2026-07-14 14:44:30 +00:00
parent 0443f5a811
commit 9b360c4010
18 changed files with 311 additions and 86 deletions

View File

@ -10,7 +10,7 @@
}
],
"require": {
"php": "^8.2",
"php": "^8.4",
"php-di/php-di": "dev-master",
"ext-json": "*",
"mcnd/lean": "dev-master"

View File

@ -2,6 +2,8 @@
use Ulmus\ConnectionAdapter;
use function DI\{ get, env };
define('LEAN_API_PROJECT_PATH', $path = dirname(__DIR__, 2));
return [
@ -56,6 +58,11 @@ return [
return $adapter;
},
'app.middlewares' => \DI\add([
Lean\Api\Middleware\CORS::class,
]),
Lean\Api\Lib\RenderCORS::class => DI\create(Lean\Api\Lib\RenderCORS::class)->constructor(get(\Psr\Http\Message\ServerRequestInterface::class), env('LEAN_API_CORS_ORIGIN', '*')),
Lean\ApplicationStrategy\NotFoundDecoratorInterface::class => DI\autowire(Lean\Api\ApplicationStrategy\NotFoundDecorator::class),
Lean\ApplicationStrategy\MethodNotAllowedInterface::class => DI\autowire(Lean\Api\ApplicationStrategy\MethodNotAllowedDecorator::class),
Lean\Api\Factory\MessageFactoryInterface::class => DI\autowire(Lean\Api\Lib\Message::class),

View File

@ -3,6 +3,7 @@
namespace Lean\Api\ApplicationStrategy;
use DI\Attribute\Inject;
use Lean\Api\Lib\RenderCORS;
use Lean\ApplicationStrategy;
use Lean\Factory\HttpFactoryInterface;
use Picea\Picea;
@ -13,17 +14,17 @@ use Psr\Http\Server\RequestHandlerInterface;
class MethodNotAllowedDecorator implements \Lean\ApplicationStrategy\MethodNotAllowedInterface
{
public function __construct(
protected RenderCORS $cors,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() === 'OPTIONS') {
return $this->cors->fromResponse($handler->handle($request), true);
}
if ($request->getMethod() === 'OPTIONS') {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: *');
header('Access-Control-Allow-Headers: *');
header('Access-Control-Allow-Credentials: true');
exit(0);
}
return $handler->handle($request);;
return $handler->handle($request);
}
}

11
src/Attribute/Action.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace Lean\Api\Attribute;
use Lean\Api\Attribute\Action\ActionTypeEnum;
use Picea\Ui\Method\FormContextInterface;
use Ulmus\Entity\EntityInterface;
abstract class Action {
abstract public function action(EntityInterface $entity, string $key, FormContextInterface $context, string $field): ActionTypeEnum;
}

View File

@ -0,0 +1,9 @@
<?php
namespace Lean\Api\Attribute\Action;
enum ActionTypeEnum
{
case ValueModifier;
case Setter;
}

View File

@ -0,0 +1,21 @@
<?php
namespace Lean\Api\Attribute\Action;
use Picea\Ui\Method\FormContextInterface;
use Ulmus\Entity\EntityInterface;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Trim extends \Lean\Api\Attribute\Action
{
public function __construct(
public string $characters = " \n\r\t\v\0"
) {}
public function action(EntityInterface $entity, string $key, FormContextInterface $context, string $field): ActionTypeEnum
{
$context->$field = trim($context->$field, $this->characters);
return ActionTypeEnum::ValueModifier;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Lean\Api\Attribute\Action;
use Picea\Ui\Method\FormContextInterface;
use Ulmus\Entity\EntityInterface;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValueAppend extends \Lean\Api\Attribute\Action
{
public function action(EntityInterface $entity, string $key, FormContextInterface $context, string $field): ActionTypeEnum
{
dump($entity->$key);
array_push($entity->$key, $context->$field);
return ActionTypeEnum::Setter;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Lean\Api\Attribute\Action;
use Picea\Ui\Method\FormContextInterface;
use Ulmus\Entity\EntityInterface;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValuePrepend extends \Lean\Api\Attribute\Action
{
public function action(EntityInterface $entity, string $key, FormContextInterface $context, string $field): ActionTypeEnum
{
array_unshift($entity->$key, $context->$field);
return ActionTypeEnum::Setter;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Lean\Api\Attribute\Action;
use Picea\Ui\Method\FormContextInterface;
use Ulmus\Entity\EntityInterface;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValueSet extends \Lean\Api\Attribute\Action
{
public function action(EntityInterface $entity, string $key, FormContextInterface $context, string $field): ActionTypeEnum
{
$entity->$key = $context->$field;
return ActionTypeEnum::Setter;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace Lean\Api\Attribute;
enum ContextFieldActionEnum: string
{
case Replace = "replace";
case ArrayAppend = "array-append";
case ArrayPrepend = "array-prepend";
case Trim = "trim";
}

View File

@ -28,7 +28,7 @@ use Psr\Http\Message\{ServerRequestInterface, ResponseInterface};
#[color:gray, bg: black]║ ║[#]
#[color:gray, bg: black]╚═══════════════════════════════════════════════════════════════════╝[#]
Try #[color:gray, bg: black]'lean api --help'[#] for more information.
Try #[color:gray, bg: black]'cli lean-api --help'[#] for more information.
CLI
, command: 'lean-api')]
#[Option(switch: ['-h', '--help'], description: "Display help for the given command. When no command is given display help for the list command")]

View File

@ -0,0 +1,13 @@
<?php
namespace Lean\Api\Entity;
use Picea\Ui\Method\Context\FormContextCastableInterface;
class ArrayCollection extends \Ulmus\Entity\ArrayCollection implements FormContextCastableInterface
{
public static function instance(mixed $value = [], null|string $entityClass = null) : self
{
return parent::instance($value, $entityClass);
}
}

View File

@ -5,7 +5,7 @@ namespace Lean\Api\Form;
use CSLSJ\Lean\Form\Session\EmailContext;
use Picea\Ui\Method\{FormContext, FormContextInterface};
use Lean\Api\Attribute\ContextField;
use Lean\Api\Attribute\{Action, Action\ActionTypeEnum, ContextField, Action\ValueSet};
use Lean\Api\Exception\LeanApiException;
use Lean\Api\Exception\MandatoryFieldException;
use Lean\Api\Exception\MinimumLengthException;
@ -130,29 +130,36 @@ abstract class Save extends Form implements \Picea\Ui\Method\FormInterface {
$apiField = $entityField[0]->object ;
$var = $apiField->field ?: $key;
$attr = $this->reflectedProperties($context)[$var] ?? false;
if ( property_exists($context, $var) && ( new \ReflectionProperty($context, $var) )->isInitialized($context) ) {
$contextField = $attr ? $attr->getAttribute(ContextField::class) : null;
if ( property_exists($context, $var) && new \ReflectionProperty($context, $var)->isInitialized($context) ) {
if ($apiField->setterMethod) {
# Use a setter method
call_user_func([ $entity, $apiField->setterMethod ], $context->{$var});
}
else {
# Direct property set
$entity->$key = $context->{$var};
elseif($attr) {
$actions = [];
foreach($attr->getAttributes(Action::class) as $item) {
$actions[] = $item->object->action($entity, $key, $context, $var);
}
if (! in_array(ActionTypeEnum::Setter, $actions)) {
new ValueSet()->action($entity, $key, $context, $var);
}
}
}
elseif (! $entity->isLoaded()) {
elseif (! $entity->isLoaded() && $contextField) {
# Applies 'default' value assigned to ContextField property
$attr = $this->reflectedProperties($context)[$var] ?? null;
if ($attr) {
$contextField = $attr->getAttribute(ContextField::class);
if ($contextField) {
if (isset($contextField->object->default)) {
$entity->$key = $contextField->object->default;
}
if (isset($contextField->object->default)) {
if (is_callable($contextField->object->default)) {
$entity->$key = call_user_func_array($contextField->object->default, [ $context, ]);
}
else {
$entity->$key = $contextField->object->default;
}
}
}

View File

@ -10,42 +10,41 @@ use Ulmus\Entity\EntityInterface;
class FormContext extends \Picea\Ui\Method\FormContext {
public function __construct(
protected LanguageHandler $languageHandler,
protected MessageFactoryInterface $message,
ServerRequestInterface $request,
protected LanguageHandler $formLanguageHandler,
protected MessageFactoryInterface $formMessage,
ServerRequestInterface $formContextRequest,
? string $formName = null
)
{
parent::__construct($request, $formName);
) {
parent::__construct($formContextRequest, $formName);
}
# public function initializeEntity(EntityInterface $entity) : void {}
public function lang(string $key, array $variables = [])
{
return $this->languageHandler->languageFromKey($key, $variables);
return $this->formLanguageHandler->languageFromKey($key, $variables);
}
public function pushSuccessMessage($key, $variables = []) : void {
$this->pushMessage($this->message::generateSuccess(
$this->pushMessage($this->formMessage::generateSuccess(
$this->lang($key, $variables)
));
}
public function pushWarningMessage($key, $variables = []) : void {
$this->pushMessage($this->message::generateWarning(
$this->pushMessage($this->formMessage::generateWarning(
$this->lang($key, $variables)
));
}
public function pushErrorMessage($key, $variables = []) : void {
$this->pushMessage($this->message::generateError(
$this->pushMessage($this->formMessage::generateError(
$a = $this->lang($key, $variables)
));
}
public function pushInfoMessage($key, $variables = []) : void {
$this->pushMessage($this->message::generateInfo(
$this->pushMessage($this->formMessage::generateInfo(
$this->lang($key, $variables)
));
}

93
src/Lib/RenderCORS.php Normal file
View File

@ -0,0 +1,93 @@
<?php
namespace Lean\Api\Lib;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Ulmus\Api\Stream\Stream;
class RenderCORS
{
public function __construct(
protected ServerRequestInterface $request,
public string|array $origin = "http://localhost,https://localhost",
public bool $allowCredentials = true,
public string|array $methods = [ "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", ],
public string|array $headers = [ "Content-Type", "Authorization", "X-Requested-With" ],
) {
if (is_string($this->origin)) {
$this->origin = explode(',', $this->origin);
}
}
public function renderHeader(ServerRequestInterface $request) : void
{
$matched = $this->matchOrigin($this->request->getServerParams()['HTTP_ORIGIN'] ?? null, $this->origin);
if (! $matched) {
return;
}
$render = fn(string|array $e) => is_string($e) ? $e : sprintf('%s', implode(', ', $e));
header('Access-Control-Allow-Origin: ' . $matched);
header(sprintf('Access-Control-Allow-Methods: %s', $render($this->methods)));
header(sprintf('Access-Control-Allow-Headers: %s', $render($this->headers)));
header('Access-Control-Allow-Credentials: ' . ($this->allowCredentials ? 'true' : 'false'));
}
public function fromResponse(ResponseInterface $response, bool $emptyResponse = false) : ResponseInterface
{
$matched = $this->matchOrigin($this->request->getServerParams()['HTTP_ORIGIN'] ?? null, $this->origin);
if (! $matched) {
return $response;
}
$render = fn(string|array $e) => is_string($e) ? $e : sprintf('%s', implode(', ', $e));
$response = $response
->withHeader('Access-Control-Allow-Origin', $matched)
->withHeader('Access-Control-Allow-Methods', $render($this->methods))
->withHeader('Access-Control-Allow-Headers', $render($this->headers))
->withHeader('Access-Control-Allow-Credentials', $this->allowCredentials ? 'true' : 'false');
if ($emptyResponse) {
$response = $response->withBody(Stream::fromMemory())->withStatus(200);
}
return $response;
}
function matchOrigin(?string $origin, string|array $allowList): ?string
{
if ($origin === null || $origin === '') {
return null;
}
foreach ((array) $allowList as $rule) {
if ($rule === '*') {
return '*';
}
// Regex rule: "/.../"
if (is_string($rule) && strlen($rule) >= 2 && $rule[0] === '/' && substr($rule, -1) === '/') {
if (@preg_match($rule, $origin) === 1) {
return $origin;
}
continue;
}
// Exact match
if (is_string($rule) && hash_equals($rule, $origin)) {
return $origin;
}
}
return null;
}
}

View File

@ -2,23 +2,20 @@
namespace Lean\Api\Middleware;
use Laminas\Diactoros\Response\JsonResponse;
use Lean\Factory\HttpFactory;
use Picea\Ui\Method\FormHandler;
use Lean\Api\Lib\RenderCORS;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Lean\Api\{Factory\DebugFormFactoryInterface, Form, Entity};
class CORS implements MiddlewareInterface {
class CORS implements MiddlewareInterface
{
public function __construct(
protected RenderCORS $cors,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
{
if ($request->getMethod() === "option") {
die("an option :)");
}
return $handler->handle($request);
return $this->cors->fromResponse($handler->handle($request));
}
}

View File

@ -13,47 +13,50 @@
<div class='description'>{{ $form['description'] }}</div>
<hr style="margin-top:15px">
{% if $form['fields'] %}
<div class="fields-wrapper">
<div class="header-fields">Champs</div>
<div class="fields-wrapper">
<div class="header-fields">Champs</div>
<ol class='fields'>
{% foreach $form['fields'] as $field %}
<li class="odd-even">
<div class="title">
<div>
<strong style="font-family:monospace">{{ $field['name'] }} {% if $field['mandatory'] %}<span style="color:red">*</span>{% endif %}</strong>
<span style='margin-left:15px'>{{ $field['description'] }}</span>
<ol class='fields'>
{% foreach $form['fields'] as $field %}
<li class="odd-even">
<div class="title">
<div>
<strong style="font-family:monospace">{{ $field['name'] }} {% if $field['mandatory'] %}<span style="color:red">*</span>{% endif %}</strong>
<span style='margin-left:15px'>{{ $field['description'] }}</span>
</div>
</div>
</div>
<div class="field-desc" style="margin-top:10px;;background:#fff;padding:5px;font-size:0.9em">
<div><u>Variable POST / champ JSON</u> : {{ $field['name'] }}</div>
<div><u>Type(s)</u> : {{ implode(' | ', array_map(fn($e) => $e->type, $field['type'])) }}</div>
<div><u>Nullable</u> : {{ yesOrNo($field['allowNulls']) }}</div>
{% if $field['regexPattern'] !== null %}
<div><u>Pattern regex</u> : {{ $field['regexPattern'] }}</div>
{% endif %}
{% if $field['minLength'] !== null %}
<div><u>Taille min.</u> : {{ $field['minLength'] }}</div>
{% endif %}
{% if $field['maxLength'] !== null %}
<div><u>Taille max.</u> : {{ $field['maxLength'] }}</div>
{% endif %}
{% if $field['example'] !== null || $field['values'] %}
<div><u>Exemple</u> : {{ $field['example'] }}</div>
{% endif %}
{% if $field['default'] !== null %}
<div><u>Valeur par défaut</u> : {{ $field['default'] }}</div>
{% endif %}
{% if $field['values'] %}
<div><u>Valeurs possibles</u> : [ <u class="enum-value">{{= implode('</u><u class="enum-value">', $field['values']) }}</u> ]</div>
{% endif %}
</div>
</li>
{% endforeach %}
</ol>
</div>
<div class="field-desc" style="margin-top:10px;;background:#fff;padding:5px;font-size:0.9em">
<div><u>Variable POST / champ JSON</u> : {{ $field['name'] }}</div>
<div><u>Type(s)</u> : {{ implode(' | ', array_map(fn($e) => $e->type, $field['type'])) }}</div>
<div><u>Nullable</u> : {{ yesOrNo($field['allowNulls']) }}</div>
{% if $field['regexPattern'] !== null %}
<div><u>Pattern regex</u> : {{ $field['regexPattern'] }}</div>
{% endif %}
{% if $field['minLength'] !== null %}
<div><u>Taille min.</u> : {{ $field['minLength'] }}</div>
{% endif %}
{% if $field['maxLength'] !== null %}
<div><u>Taille max.</u> : {{ $field['maxLength'] }}</div>
{% endif %}
{% if $field['example'] !== null || $field['values'] %}
<div><u>Exemple</u> : {{ $field['example'] }}</div>
{% endif %}
{% if $field['default'] !== null %}
<div><u>Valeur par défaut</u> : {{ $field['default'] }}</div>
{% endif %}
{% if $field['values'] %}
<div><u>Valeurs possibles</u> : [ <u class="enum-value">{{= implode('</u><u class="enum-value">', $field['values']) }}</u> ]</div>
{% endif %}
</div>
</li>
{% endforeach %}
</ol>
</div>
{% else %}
<div class="empty">Aucun champ adressable pour ce context</div>
{% endif %}
</div>
{% or %}
<i style="color:#585858; padding:0 12px">{% _ "none" %}</i>

View File

@ -28,6 +28,7 @@
h2 + p {font-family: monospace, serif;font-size: 0.9rem;}
.hide {display:none!important}
.empty {font-size:0.9rem; color:gray; text-align:center; line-height:2.5rem; background: rgba(0, 0, 0, 0.07); margin:0 0 10px 0}
ul {background:#f4f4f4; list-style: none; padding-left:20px}
ul ul {margin: 0;border: 0;padding: 5px 30px;}