Skip to content

[api/lib] instrument validation #9827

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: 26.0-release
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions htdocs/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@
// See: https://www.php.net/manual/en/session.configuration.php#ini.session.use-strict-mode
ini_set('session.use_strict_mode', '1');

// TODO: Remove this code once PHP 8.4 becomes the minimal PHP version in LORIS.
if (version_compare(PHP_VERSION, '8.4', '<')) {
// @phan-file-suppress PhanRedefineFunctionInternal

// phpcs:ignore
function array_all(array $array, callable $callable): bool {
foreach ($array as $key => $value) {
if (!$callable($value, $key))
return false;
}
return true;
}
}

// FIXME: The code in NDB_Client should mostly be replaced by middleware.
$client = new \NDB_Client;
$client->initialize();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,16 @@ class Flags extends Endpoint implements \LORIS\Middleware\ETagCalculator
'Invalid request'
);
}
if (!$this->_instrument->validate($data)) {
try {
$this->_instrument->validate($data);
} catch (\LorisException $th) {
return new \LORIS\Http\Response\JSON\BadRequest(
"Could not update. {$th->getMessage()}"
);
} catch (\Throwable $th) {
error_log($th->getMessage());
return new \LORIS\Http\Response\JSON\Forbidden(
'Could not update.'
"Could not update."
);
}

Expand Down Expand Up @@ -242,9 +249,16 @@ class Flags extends Endpoint implements \LORIS\Middleware\ETagCalculator
);
}

if (!$this->_instrument->validate($data)) {
try {
$this->_instrument->validate($data);
} catch (\LorisException $th) {
return new \LORIS\Http\Response\JSON\BadRequest(
"Could not update. {$th->getMessage()}"
);
} catch (\Throwable $th) {
error_log($th->getMessage());
return new \LORIS\Http\Response\JSON\Forbidden(
'Could not update.'
"Could not update."
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,21 @@ class Instrument extends Endpoint implements \LORIS\Middleware\ETagCalculator
);
}

if (!$this->_instrument->validate($data)) {
// validate given data against the instrument
try {
$this->_instrument->validate($data);
} catch (\LorisException $th) {
return new \LORIS\Http\Response\JSON\BadRequest(
"Could not update. {$th->getMessage()}"
);
} catch (\Throwable $th) {
error_log($th->getMessage());
return new \LORIS\Http\Response\JSON\Forbidden(
'Could not update.'
"Could not update."
);
}

// update values
try {
$this->_instrument->clearInstrument();
$version = $request->getAttribute('LORIS-API-Version');
Expand Down Expand Up @@ -230,9 +239,17 @@ class Instrument extends Endpoint implements \LORIS\Middleware\ETagCalculator
);
}

if (!$this->_instrument->validate($data)) {
// validate given data against the instrument
try {
$this->_instrument->validate($data);
} catch (\LorisException $th) {
return new \LORIS\Http\Response\JSON\BadRequest(
"Could not update. {$th->getMessage()}"
);
} catch (\Throwable $th) {
error_log($th->getMessage());
return new \LORIS\Http\Response\JSON\Forbidden(
'Could not update.'
"Could not update."
);
}

Expand Down
209 changes: 205 additions & 4 deletions php/libraries/NDB_BVL_Instrument.class.inc
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,20 @@ abstract class NDB_BVL_Instrument extends NDB_Page
*/
protected $selectMultipleElements = [];

/**
* Array containing all metadata elements in an instrument.
*
* @var array
*
* @access protected
*/
protected array $metadataElements = [
"Date_taken",
"Examiner",
"Window_Difference",
"Candidate_Age",
];

/**
* Factory generates a new instrument instance of type
* $instrument, and runs the setup() method on that new
Expand Down Expand Up @@ -2958,15 +2972,202 @@ abstract class NDB_BVL_Instrument extends NDB_Page
}

/**
* Validate whether the values submitted are valid
* Validate the given array keys against the instrument default structure.
* Throw a LorisException if any check does not pass.
*
* @param array $instrumentData instrument data to check
*
* @throws \LorisException
*
* @return void
*/
private function _validateKeys(
array $instrumentData
): void {
// get keys from new data
$dataKeys = array_keys($instrumentData);

// load default instrument data keys
$defaultDictionary = $this->defaultInstanceData();
$defaultDataKeys = array_keys($defaultDictionary);

// filter out metadata fields, keep only instrument data fields
$defaultDataKeys = array_values(
array_filter(
$defaultDataKeys,
fn($k) => !in_array(
$k,
$this->metadataElements,
true
)
)
);

// missing keys
$missingKeys = array_diff($defaultDataKeys, $dataKeys);
if (!empty($missingKeys)) {
$i = implode(",", $missingKeys);
throw new \LorisException("Missing keys: {$i}.");
}

// additional keys: only warn
$additionalKeys = array_diff($dataKeys, $defaultDataKeys);
if (!empty($additionalKeys)) {
$i = implode(",", $additionalKeys);
error_log(
"[instrument:validation] Additional keys will be ignored: {$i}."
);
}
}

/**
* Validate the given array values against the instrument dictionary items.
* Throw a LorisException if any check does not pass.
*
* @param array $instrumentData instrument data to check
*
* @throws \LorisException
*
* @return void
*/
private function _validateValues(
array $instrumentData
): void {
// get datetime fn, and format from db
$getDateTime = fn($s, $f) => DateTime::createFromFormat($f, $s);
$config = $this->loris->getConfiguration();
$dateFormat = $config->getSetting('dateDisplayFormat');

// define all check fn - primitive
$isString = fn($f) => is_string($f) && "{$f}" === $f;
$isBoolean = fn($f) => is_bool($f) && (bool)$f === $f;
$isInteger = fn($f) => is_integer($f) && (int)$f === $f;
$isFloat = fn($f) => is_float($f) && (float)$f === $f;

// define all check fn - duration fn
$isDuration = fn($f) => $isInteger($f) && $f >= 0;

// define all check fn - date/time fn
$isDate = fn($f, $fmt) => ($getDateTime($f, $fmt) !== false)
&& ($getDateTime($f, $fmt)->format($fmt) === $f);
$isTime = fn($f, $fmt) => ($getDateTime("2025-10-10 {$f}", $fmt) !== false)
&& ($getDateTime("2025-10-10 {$f}", $fmt)->format("H:i:s") === $f);

// check types
foreach ($this->getDataDictionary() as $dictionaryItem) {
// current fieldname
$fieldName = $dictionaryItem->fieldname;

// skip filtered out fields
if (in_array($fieldName, $this->metadataElements, true)) {
continue;
}

// get the expected type for that field
$expectedType = $dictionaryItem->getDataType();

// get the field data value
$fieldToCheck = $instrumentData[$fieldName];

// if an enumeration, get the possible option keys
$optionKeys = match ("{$expectedType}") {
"enumeration" => $expectedType->getOptions(),
default => null
};

// is it a single/multi-enumeration
$isMultiEnum = in_array(
$fieldName,
$this->selectMultipleElements,
true
);

// run the validation method depending on the field type
$isValid = match ("{$expectedType}") {
"string", "URI" => $isString($fieldToCheck),
"boolean" => $isBoolean($fieldToCheck),
"integer" => $isInteger($fieldToCheck),
"decimal" => $isFloat($fieldToCheck),
"date" => $isDate($fieldToCheck, $dateFormat),
"time" => $isTime($fieldToCheck, $dateFormat),
"duration" => $isDuration($fieldToCheck),
"enumeration" => $isString($fieldToCheck) && (
$isMultiEnum
// select: the given value must be in the list of options
? in_array(
$fieldToCheck,
$optionKeys,
true
)
// multi-select: ALL given values must be in the list of options
: array_all(
explode("{@}", $fieldToCheck),
fn($v) => in_array(
$v,
$optionKeys,
true
)
)
),
default => throw new \LorisException(
"Unknown type '{$expectedType}' for field: {$fieldToCheck}"
)
};

// if not valid, format exception message
if (!$isValid) {
// expected format
$expectedFormat = match ("{$expectedType}") {
"date" => " (format: 'YYYY-MM-DD')",
"time" => " (format: 'HH:mm:ss')",
"enumeration" => ", possible answers: '"
. implode("','", $optionKeys)
. "'",
default => "",
};

// multi-enumeration?
$multi = "";
if ($isMultiEnum) {
$multi = "multi-";
// add delimiter info
$expectedFormat .= " with delimiter '{@}'";
}

// message
$msg = "Field not valid: {$fieldName}. ";
$msg .= "Expected: {$multi}{$expectedType}";
$msg .= "{$expectedFormat}";

//
throw new \LorisException($msg);
}
}
}

/**
* Validate whether the data submitted are valid against this instrument.
* Checks both keys and values. Throws a LorisException if any test fails.
*
* @param array $values an array of values submitted
*
* @return boolean true if values are valid
* @throws \LorisException
*
* @return void
*/
function validate(array $values): bool
public function validate(array $values): void
{
return $this->determineDataEntryAllowed();
// data to check even exist
$dataToCheck = $values[$this->testName] ?? null;
if ($dataToCheck === null) {
throw new \LorisException("No instrument key provided.");
}

// validate the keys against the instrument dictionary entries
$this->_validateKeys($dataToCheck);

// validate the values against the instrument dictionary entries
$this->_validateValues($dataToCheck);
}

/**
Expand Down
Loading