Skip to content

Commit 7e84535

Browse files
feat(Enum): add model and endpoint for calculate field choices enums
1 parent 0b0a5f9 commit 7e84535

4 files changed

Lines changed: 232 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace RESTAPI\Endpoints;
4+
5+
require_once 'RESTAPI/autoloader.inc';
6+
7+
use RESTAPI\Core\Endpoint;
8+
9+
/**
10+
* Defines an Endpoint for calculating enums for specific model fields.
11+
*/
12+
class SystemEnumEndpoint extends Endpoint {
13+
public function __construct() {
14+
# Set Endpoint attributes
15+
$this->url = '/api/v2/system/enum';
16+
$this->model_name = 'Enum';
17+
$this->request_method_options = ['POST'];
18+
$this->post_help_text = 'Enumerate all possible choices for a given model field.';
19+
20+
# Construct the parent Endpoint object
21+
parent::__construct();
22+
}
23+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace RESTAPI\Models;
4+
5+
use RESTAPI;
6+
use RESTAPI\Core\Command;
7+
use RESTAPI\Core\Model;
8+
use RESTAPI\Fields\BooleanField;
9+
use RESTAPI\Fields\ForeignModelField;
10+
use RESTAPI\Fields\ObjectField;
11+
use RESTAPI\Fields\StringField;
12+
use RESTAPI\Responses\ConflictError;
13+
use RESTAPI\Responses\NotFoundError;
14+
15+
/**
16+
* Defines a Model for calculating all possible choices for a given model and field. This is used as a definitive
17+
* source for determine what choices are available to a field, whether it's static choices, dynamic choices from
18+
* a choices callable, or a foreign model reference via a ForeignModelField
19+
*/
20+
class Enum extends Model {
21+
public StringField $model_name;
22+
public StringField $model_field;
23+
public ObjectField $choices;
24+
25+
public function __construct(mixed $id = null, mixed $parent_id = null, mixed $data = [], mixed ...$options) {
26+
# Define model attributes
27+
$this->many = false;
28+
$this->internal_callable = 'void_callable';
29+
30+
# Define model Fields
31+
$this->model_name = new StringField(
32+
required: true,
33+
help_text: "The name of the model whose field's choices are being requested.",
34+
);
35+
$this->model_field = new StringField(
36+
required: true,
37+
help_text: 'The name of the field whose choices are being requested.',
38+
);
39+
$this->choices = new ObjectField(
40+
default: null,
41+
read_only: true,
42+
help_text: 'The available choices for the specified model and field. The key represents the internal ' .
43+
'name of the choice, and the value is the verbose, human-friendly name of the choice.',
44+
);
45+
46+
parent::__construct($id, $parent_id, $data, ...$options);
47+
}
48+
49+
/**
50+
* A void callable to mock an internal callable. This Model does not support reads.
51+
*/
52+
public function void_callable(): array {
53+
return [];
54+
}
55+
56+
/**
57+
* Ensures the requested `model_name` is actually a valid model.
58+
*
59+
* @param string $model_name The $model_name value to be validated.
60+
* @returns string The validated $model_name to be set.
61+
* @throws NotFoundError if the specific model does not exist.
62+
*/
63+
public function validate_model_name(string $model_name): string {
64+
if (!in_array($model_name, Model::get_all_model_classes(shortnames: true))) {
65+
throw new NotFoundError(
66+
message: "Model '$model_name' does not exist.",
67+
response_id: 'ENUM_MODEL_DOES_NOT_EXIST',
68+
);
69+
}
70+
71+
return $model_name;
72+
}
73+
74+
/**
75+
* Ensure the selected `model_field` is actually a field within the requested model.
76+
*
77+
* @param string $model_field The name of the field to validate.
78+
* @return string The validated $field_name value to be set.
79+
* @throws NotFoundError if the specified field does not exist on the specified model.
80+
*/
81+
public function validate_model_field(string $model_field): string {
82+
$model_fqn = "\\RESTAPI\\Models\\{$this->model_name->value}";
83+
$model = new $model_fqn(skip_init: true);
84+
85+
if (!in_array($model_field, $model->get_fields())) {
86+
throw new NotFoundError(
87+
message: "Field '$model_field' does not exist on model '{$this->model_name->value}'.",
88+
response_id: 'ENUM_MODEL_FIELD_DOES_NOT_EXIST',
89+
);
90+
}
91+
92+
return $model_field;
93+
}
94+
95+
public function _create(): void {
96+
$model_field = $this->model_field->value;
97+
$model_fqn = "\\RESTAPI\\Models\\{$this->model_name->value}";
98+
$model = new $model_fqn(skip_init: true);
99+
100+
# If this model has static choices, set all available choices
101+
if ($model->$model_field->choices) {
102+
$this->choices->value = $model->$model_field->verbose_choices;
103+
}
104+
105+
# If this model has a choices callable, resolve the choices
106+
elseif ($model->$model_field->choices_callable) {
107+
$model->$model_field->set_choices_from_callable();
108+
$this->choices->value = $model->$model_field->verbose_choices;
109+
}
110+
111+
# If this model is a ForeignModelField, resolve the choices from the foreign model
112+
elseif ($model->$model_field instanceof ForeignModelField) {
113+
$f_model_field = $model->$model_field->model_field;
114+
$in_scope_modelsets = $model->$model_field->get_in_scope_models();
115+
foreach ($in_scope_modelsets as $in_scope_modelset) {
116+
foreach ($in_scope_modelset->model_objects as $model_object) {
117+
$this->choices->value[$model_object->$f_model_field->value] = $model_object->$f_model_field->value;
118+
}
119+
}
120+
} else {
121+
throw new ConflictError(
122+
message: "Field '$model_field' does not support choices.",
123+
response_id: 'ENUM_MODEL_FIELD_DOES_NOT_SUPPORT_CHOICES',
124+
);
125+
}
126+
}
127+
}

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/Test.inc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class Test extends Model {
2222
public Field $test_string_unique;
2323
public Field $test_string_many;
2424
public Field $test_string_namespace;
25+
public StringField $test_string_choices;
26+
public StringField $test_string_choices_callable;
2527

2628
public function __construct(mixed $id = null, mixed $parent_id = null, array $data = [], mixed ...$options) {
2729
# Mark this model as a `many` model indicating multiple Test model objects can be created. This is also
@@ -73,6 +75,18 @@ class Test extends Model {
7375
internal_namespace: 'test_namespace',
7476
help_text: 'Demonstrates a string field that is nested under the namespace ',
7577
);
78+
$this->test_string_choices = new StringField(
79+
default: '',
80+
choices: ['a' => 'Choice A', 'b' => 'Choice B', 'c' => 'Choice C'],
81+
allow_empty: true,
82+
help_text: 'Demonstrates a string field with static choices defined.',
83+
);
84+
$this->test_string_choices_callable = new StringField(
85+
default: '',
86+
choices_callable: 'test_choices_callable',
87+
allow_empty: true,
88+
help_text: 'Demonstrates a string field with static choices defined.',
89+
);
7690

7791
# Parent base 'Model' object must be constructed very last to ensure the Model has all configured attributes.
7892
parent::__construct($id, $parent_id, $data, ...$options);
@@ -85,6 +99,17 @@ class Test extends Model {
8599
return [];
86100
}
87101

102+
/**
103+
* Defines a test choices callable for the test_string_choices_callable field
104+
*/
105+
public function test_choices_callable(): array {
106+
return [
107+
'x' => 'Choice X',
108+
'y' => 'Choice Y',
109+
'z' => 'Choice Z',
110+
];
111+
}
112+
88113
/**
89114
* Override the default apply method to write a file to disk. This is used for testing to determine if the
90115
* `apply` method did or didn't run when expected.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace RESTAPI\Tests;
4+
5+
use RESTAPI\Core\Command;
6+
use RESTAPI\Core\TestCase;
7+
use RESTAPI\Models\CARP;
8+
use RESTAPI\Models\Enum;
9+
use RESTAPI\Models\VirtualIP;
10+
11+
class APIModelsEnumTestCase extends TestCase {
12+
/**
13+
* Ensure we can calculate an enum for model fields with static choices
14+
*/
15+
public function test_choices_enum(): void {
16+
# Execute an enumeration of the Test models' test_string_choices field which has static choices defined
17+
$enum = new Enum(model_name: 'Test', model_field: 'test_string_choices');
18+
$enum->create();
19+
$this->assert_equals($enum->model_name->value, 'Test');
20+
$this->assert_equals($enum->model_field->value, 'test_string_choices');
21+
$this->assert_equals($enum->choices->value, [
22+
'a' => 'Choice A',
23+
'b' => 'Choice B',
24+
'c' => 'Choice C',
25+
]);
26+
}
27+
28+
/**
29+
* Ensure we can calculate an enum for model fields with dynamic choices (choice_callable)
30+
*/
31+
public function test_choices_callable_enum(): void {
32+
# Execute an enumeration of the Test models' test_string_choices_callable field with dynamic choices defined
33+
$enum = new Enum(model_name: 'Test', model_field: 'test_string_choices_callable');
34+
$enum->create();
35+
$this->assert_equals($enum->model_name->value, 'Test');
36+
$this->assert_equals($enum->model_field->value, 'test_string_choices_callable');
37+
$this->assert_equals($enum->choices->value, [
38+
'x' => 'Choice X',
39+
'y' => 'Choice Y',
40+
'z' => 'Choice Z',
41+
]);
42+
}
43+
44+
/**
45+
* Ensure we can calculate an enum for ForeignModelFields
46+
*/
47+
public function test_foreign_model_field_enum(): void {
48+
$enum = new Enum(model_name: 'StaticRoute', model_field: 'gateway');
49+
$enum->create();
50+
$this->assert_equals($enum->model_name->value, 'StaticRoute');
51+
$this->assert_equals($enum->model_field->value, 'gateway');
52+
$this->assert_equals($enum->choices->value, [
53+
'WAN_DHCP' => 'WAN_DHCP',
54+
'WAN_DHCP6' => 'WAN_DHCP6',
55+
]);
56+
}
57+
}

0 commit comments

Comments
 (0)