Compare commits

...

5 Commits

Author SHA1 Message Date
Anne-Cath
6cfaebfbcf Add tests 2025-04-01 16:41:56 +02:00
Anne-Cath
ab2b1a18b0 WIP 2025-04-01 16:16:51 +02:00
Anne-Cath
d241bc94bd WIP 2025-04-01 15:12:14 +02:00
Anne-Cath
1d3e7ee7cb WIP 2025-04-01 13:31:15 +02:00
Anne-Cath
4b05c80416 Rest on multi classes 2025-04-01 09:56:56 +02:00
4 changed files with 461 additions and 66 deletions

View File

@@ -1929,41 +1929,76 @@ class RestUtils
* @return array of class => list of attributes (see RestResultWithObjects::AddObject that uses it)
* @throws Exception
*/
public static function GetFieldList($sClass, $oData, $sParamName)
public static function GetFieldList($sClass, $oData, $sParamName, $bFailIfNotFound = true)
{
$sFields = self::GetOptionalParam($oData, $sParamName, '*');
$aShowFields = array();
if ($sFields == '*')
{
foreach (MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef)
{
$aShowFields[$sClass][] = $sAttCode;
}
return match($sFields) {
'*' => self::GetFieldListForClass($sClass),
'*+' => self::GetFieldListForParentClass($sClass),
default => self::GetLimitedFieldListForClass($sClass, $sFields, $sParamName, $bFailIfNotFound),
};
}
public static function HasRequestedExtendedOutput(string $sFields): bool
{
return match($sFields) {
'*' => false,
'*+' => true,
default => substr_count($sFields, ':') > 1,
};
}
public static function HasRequestedAllOutputFields(string $sFields): bool
{
return match($sFields) {
'*', '*+' => true,
default => false,
};
}
protected static function GetFieldListForClass(string $sClass): array
{
return [$sClass => array_keys(MetaModel::ListAttributeDefs($sClass))];
}
protected static function GetFieldListForParentClass(string $sClass): array
{
$aFieldList = array();
foreach (MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL) as $sRefClass) {
$aFieldList = array_merge($aFieldList, self::GetFieldListForClass($sRefClass));
}
elseif ($sFields == '*+')
{
foreach (MetaModel::EnumChildClasses($sClass, ENUM_CHILD_CLASSES_ALL) as $sRefClass)
{
foreach (MetaModel::ListAttributeDefs($sRefClass) as $sAttCode => $oAttDef)
{
$aShowFields[$sRefClass][] = $sAttCode;
return $aFieldList;
}
protected static function GetLimitedFieldListForSingleClass(string $sClass, string $sFields, string $sParamName, bool $bFailIfNotFound = true): array
{
$aFieldList = [$sClass => []];
foreach (explode(',', $sFields) as $sAttCode) {
$sAttCode = trim($sAttCode);
if (($sAttCode == 'id') || (MetaModel::IsValidAttCode($sClass, $sAttCode))) {
$aFieldList[$sClass][] = $sAttCode;
} else {
if ($bFailIfNotFound) {
throw new Exception("$sParamName: invalid attribute code '$sAttCode' for class '$sClass'");
}
}
}
else
{
foreach (explode(',', $sFields) as $sAttCode)
{
$sAttCode = trim($sAttCode);
if (($sAttCode != 'id') && (!MetaModel::IsValidAttCode($sClass, $sAttCode)))
{
throw new Exception("$sParamName: invalid attribute code '$sAttCode'");
}
$aShowFields[$sClass][] = $sAttCode;
}
return $aFieldList;
}
protected static function GetLimitedFieldListForClass(string $sClass, string $sFields, string $sParamName, bool $bFailIfNotFound = true): array
{
if (!str_contains($sFields, ':')) {
return self::GetLimitedFieldListForSingleClass($sClass, $sFields, $sParamName, $bFailIfNotFound);
}
return $aShowFields;
$aFieldList = [];
$aFieldListParts = explode(';', $sFields);
foreach ($aFieldListParts as $sClassFields) {
list($sSubClass, $sSubClassFields) = explode(':', $sClassFields);
$aFieldList = array_merge($aFieldList, self::GetLimitedFieldListForSingleClass(trim($sSubClass), trim($sSubClassFields), $sParamName, $bFailIfNotFound));
}
return $aFieldList;
}
/**

View File

@@ -185,23 +185,7 @@ class RestResultWithObjects extends RestResult
/** @var array "DBObject_class:DBObject_key" as key, {@see \ObjectResult} as value */
public $objects;
/**
* Report the given object
*
* @api
* @param int $iCode An error code (RestResult::OK is no issue has been found)
* @param string $sMessage Description of the error if any, an empty string otherwise
* @param DBObject $oObject The object being reported
* @param array|null $aFieldSpec An array of class => attribute codes (Cf. RestUtils::GetFieldList). List of the attributes to be reported.
* @param boolean $bExtendedOutput Output all of the link set attributes ?
*
* @return void
* @throws \ArchivedObjectException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function AddObject($iCode, $sMessage, $oObject, $aFieldSpec = null, $bExtendedOutput = false)
public function PrepareObject($iCode, $sMessage, $oObject, $aFieldSpec = null, $bExtendedOutput = false)
{
$sClass = get_class($oObject);
$oObjRes = new ObjectResult($sClass, $oObject->GetKey());
@@ -232,6 +216,28 @@ class RestResultWithObjects extends RestResult
$oObjRes->AddField($oObject, $sAttCode, $bExtendedOutput);
}
return $oObjRes;
}
/**
* Report the given object
*
* @api
* @param int $iCode An error code (RestResult::OK is no issue has been found)
* @param string $sMessage Description of the error if any, an empty string otherwise
* @param DBObject $oObject The object being reported
* @param array|null $aFieldSpec An array of class => attribute codes (Cf. RestUtils::GetFieldList). List of the attributes to be reported.
* @param boolean $bExtendedOutput Output all of the link set attributes ?
*
* @return void
* @throws \ArchivedObjectException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function AddObject($iCode, $sMessage, $oObject, $aFieldSpec = null, $bExtendedOutput = false)
{
$oObjRes = $this->PrepareObject($iCode, $sMessage, $oObject, $aFieldSpec, $bExtendedOutput);
$sObjKey = get_class($oObject).'::'.$oObject->GetKey();
$this->objects[$sObjKey] = $oObjRes;
}
@@ -247,6 +253,45 @@ public function SanitizeContent()
}
}
/**
* @package RESTAPI
* @api
*/
class RestResultWithObjectSets extends RestResultWithObjects
{
private $current_object = null;
public function MakeNewObjectSet()
{
$arr = array();
$this->current_object = &$arr;
$this->objects[] = &$arr;
}
/**
* Report the given object
*
* @api
* @param string $sObjectAlias Name of the subobject, usually the OQL class alias
* @param int $iCode An error code (RestResult::OK is no issue has been found)
* @param string $sMessage Description of the error if any, an empty string otherwise
* @param DBObject $oObject The object being reported
* @param array|null $aFieldSpec An array of class => attribute codes (Cf. RestUtils::GetFieldList). List of the attributes to be reported.
* @param boolean $bExtendedOutput Output all of the link set attributes ?
*
* @return void
* @throws \ArchivedObjectException
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \MySQLException
*/
public function AppendSubObject($sObjectAlias, $iCode, $sMessage, $oObject, $aFieldSpec = null, $bExtendedOutput = false)
{
$oObjRes = $this->PrepareObject($iCode, $sMessage, $oObject, $aFieldSpec, $bExtendedOutput);
$this->current_object[$sObjectAlias] = $oObjRes;
}
}
/**
* @package RESTAPI
* @api
@@ -526,14 +571,21 @@ class CoreServices implements iRestServiceProvider, iRestInputSanitizer
break;
case 'core/get':
$sClass = RestUtils::GetClass($aParams, 'class');
$sClassParam = RestUtils::GetMandatoryParam($aParams, 'class');
$key = RestUtils::GetMandatoryParam($aParams, 'key');
$aShowFields = RestUtils::GetFieldList($sClass, $aParams, 'output_fields');
$bExtendedOutput = (RestUtils::GetOptionalParam($aParams, 'output_fields', '*') == '*+');
$sShowFields = RestUtils::GetOptionalParam($aParams, 'output_fields', '*');
$iLimit = (int)RestUtils::GetOptionalParam($aParams, 'limit', 0);
$iPage = (int)RestUtils::GetOptionalParam($aParams, 'page', 1);
$oObjectSet = RestUtils::GetObjectSetFromKey($sClass, $key, $iLimit, self::getOffsetFromLimitAndPage($iLimit, $iPage));
// Validate the class(es)
$aClass = explode(',', $sClassParam);
foreach ($aClass as $sClass) {
if (!MetaModel::IsValidClass(trim($sClass))) {
throw new Exception("class '$sClass' is not valid");
}
}
$oObjectSet = RestUtils::GetObjectSetFromKey($sClassParam, $key, $iLimit, self::getOffsetFromLimitAndPage($iLimit, $iPage));
$sTargetClass = $oObjectSet->GetFilter()->GetClass();
if (UserRights::IsActionAllowed($sTargetClass, UR_ACTION_READ) != UR_ALLOWED_YES)
@@ -551,23 +603,68 @@ class CoreServices implements iRestServiceProvider, iRestInputSanitizer
$oResult->code = RestResult::INVALID_PAGE;
$oResult->message = "The request page number is not valid. It must be an integer greater than 0";
}
else
elseif (count($oObjectSet->GetSelectedClasses()) > 1)
{
if (!$bExtendedOutput && RestUtils::GetOptionalParam($aParams, 'output_fields', '*') != '*')
{
$aFields = $aShowFields[$sClass];
//Id is not a valid attribute to optimize
if (in_array('id', $aFields))
{
unset($aFields[array_search('id', $aFields)]);
}
$aAttToLoad = array($oObjectSet->GetClassAlias() => $aFields);
$oObjectSet->OptimizeColumnLoad($aAttToLoad);
}
$oResult = new RestResultWithObjectSets();
$aCache = [];
$aShowFields = [];
foreach ($oObjectSet->GetSelectedClasses() as $sSelectedClass) {
$aShowFields = array_merge( $aShowFields, RestUtils::GetFieldList($sSelectedClass, $aParams, 'output_fields', false));
}
while ($oObject = $oObjectSet->Fetch())
{
$oResult->AddObject(0, '', $oObject, $aShowFields, $bExtendedOutput);
while ($oObjects = $oObjectSet->FetchAssoc()) {
$oResult->MakeNewObjectSet();
foreach ($oObjects as $sAlias => $oObject) {
if (!$oObject) {
continue;
}
if (!array_key_exists($sAlias, $aCache)) {
$sClass = get_class($oObject);
$bExtendedOutput = RestUtils::HasRequestedExtendedOutput($sShowFields);
if (!RestUtils::HasRequestedAllOutputFields($sShowFields)) {
$aFields = $aShowFields[$sClass];
//Id is not a valid attribute to optimize
if ($aFields && in_array('id', $aFields)) {
unset($aFields[array_search('id', $aFields)]);
}
$aAttToLoad = [$sAlias => $aFields];
$oObjectSet->OptimizeColumnLoad($aAttToLoad);
}
$aCache[$sAlias] = [
'aShowFields' => $aShowFields,
'bExtendedOutput' => $bExtendedOutput,
];
} else {
$aShowFields = $aCache[$sAlias]['aShowFields'];
$bExtendedOutput = $aCache[$sAlias]['bExtendedOutput'];
}
$oResult->AppendSubObject($sAlias, 0, '', $oObject, $aShowFields, $bExtendedOutput);
}
}
$oResult->message = "Found: ".$oObjectSet->Count();
} else {
$aShowFields =[];
foreach ($aClass as $sSelectedClass) {
$sSelectedClass = trim($sSelectedClass);
$aShowFields = array_merge($aShowFields, RestUtils::GetFieldList($sSelectedClass, $aParams, 'output_fields', false));
}
if (!RestUtils::HasRequestedAllOutputFields($sShowFields) && count($aShowFields) == 1) {
$aFields = $aShowFields[$sClass];
//Id is not a valid attribute to optimize
if (in_array('id', $aFields)) {
unset($aFields[array_search('id', $aFields)]);
}
$aAttToLoad = array($oObjectSet->GetClassAlias() => $aFields);
$oObjectSet->OptimizeColumnLoad($aAttToLoad);
}
while ($oObject = $oObjectSet->Fetch()) {
$oResult->AddObject(0, '', $oObject, $aShowFields, RestUtils::HasRequestedExtendedOutput($sShowFields));
}
$oResult->message = "Found: ".$oObjectSet->Count();
}

View File

@@ -142,6 +142,158 @@ JSON;
$this->assertJsonStringEqualsJsonString($sExpectedJsonOuput, $sJSONOutput);
}
public function testCoreApiGet_Select2SubClasses(){
// Create ticket
$description = date('dmY H:i:s');
$iIdCaller = $this->CreatePerson(1)->GetKey();
$oUserRequest = $this->CreateSampleTicket($description, 'UserRequest', $iIdCaller);
$oChange = $this->CreateSampleTicket($description, 'Change', $iIdCaller);
$iIdUserRequest = $oUserRequest->GetKey();
$iIdChange = $oChange->GetKey();
$sJSONOutput = $this->CallCoreRestApi_Internally(<<<JSON
{
"operation": "core/get",
"class": "UserRequest, Change",
"key": "SELECT UserRequest WHERE id=$iIdUserRequest UNION SELECT Change WHERE id=$iIdChange",
"output_fields": "id, description, outage"
}
JSON);
$sExpectedJsonOuput = <<<JSON
{
"code": 0,
"message": "Found: 2",
"objects": {
"UserRequest::$iIdUserRequest": {
"class": "UserRequest",
"code": 0,
"fields": {
"description": "<p>$description</p>",
"id": "$iIdUserRequest"
},
"key": "$iIdUserRequest",
"message": ""
},
"Change::$iIdChange": {
"class": "Change",
"code": 0,
"fields": {
"description": "<p>$description</p>",
"id": "$iIdChange",
"outage": "no"
},
"key": "$iIdChange",
"message": ""
}
}
}
JSON;
$this->assertJsonStringEqualsJsonString($sExpectedJsonOuput, $sJSONOutput);
}
public function testCoreApiGet_SelectTicketAndPerson(){
// Create ticket
$description = date('dmY H:i:s');
$iIdCaller = $this->CreatePerson(1)->GetKey();
$oUserRequest = $this->CreateSampleTicket($description, 'UserRequest', $iIdCaller);
$iIdUserRequest = $oUserRequest->GetKey();
$sJSONOutput = $this->CallCoreRestApi_Internally(<<<JSON
{
"operation": "core/get",
"class": "UserRequest, Change",
"key": "SELECT UR, P FROM UserRequest AS UR JOIN Person AS P ON UR.caller_id = P.id WHERE UR.id=$iIdUserRequest ",
"output_fields": "id, title, description, name, email"
}
JSON);
$sExpectedJsonOuput = <<<JSON
{
"code": 0,
"message": "Found: 1",
"objects": [{
"UR": {
"class": "UserRequest",
"code": 0,
"fields": {
"description": "<p>$description</p>",
"id": "$iIdUserRequest",
"title": "Houston, got a problem"
},
"key": "$iIdUserRequest",
"message": ""
},
"P": {
"class": "Person",
"code": 0,
"fields": {
"email": "",
"id": "$iIdCaller",
"name": "Person_1"
},
"key": "$iIdCaller",
"message": ""
}
}]
}
JSON;
$this->assertJsonStringEqualsJsonString($sExpectedJsonOuput, $sJSONOutput);
}
public function testCoreApiGetWithUnionAndDifferentOutputFields(){
// Create ticket
$description = date('dmY H:i:s');
$oUserRequest = $this->CreateSampleTicket($description);
$oChange = $this->CreateSampleTicket($description, 'Change');
$iUserRequestId = $oUserRequest->GetKey();
$sUserRequestRef = $oUserRequest->Get('ref');
$iChangeId = $oChange->GetKey();
$sChangeRef = $oChange->Get('ref');
$sJSONOutput = $this->CallCoreRestApi_Internally(<<<JSON
{
"operation": "core/get",
"class": "Ticket",
"key": "SELECT UserRequest WHERE id=$iUserRequestId UNION SELECT Change WHERE id=$iChangeId",
"output_fields": "Ticket:ref;UserRequest:ref,status,origin;Change:ref,status,outage"
}
JSON);
$sExpectedJsonOuput = <<<JSON
{
"code": 0,
"message": "Found: 2",
"objects": {
"Change::$iChangeId": {
"class": "Change",
"code": 0,
"fields": {
"outage": "no",
"ref": "$sChangeRef",
"status": "new"
},
"key": "$iChangeId",
"message": ""
},
"UserRequest::$iUserRequestId": {
"class": "UserRequest",
"code": 0,
"fields": {
"origin": "phone",
"ref": "$sUserRequestRef",
"status": "new"
},
"key": "$iUserRequestId",
"message": ""
}
}
}
JSON;
$this->assertJsonStringEqualsJsonString($sExpectedJsonOuput, $sJSONOutput);
}
public function testCoreApiCreate()
{
// Create ticket
@@ -253,12 +405,13 @@ JSON;
//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private function CreateSampleTicket($description)
private function CreateSampleTicket($description, $sType = 'UserRequest', $iIdCaller = null)
{
$oTicket = $this->createObject('UserRequest', [
$oTicket = $this->createObject($sType, [
'org_id' => $this->getTestOrgId(),
"title" => "Houston, got a problem",
"description" => $description
"description" => $description,
"caller_id" => $iIdCaller,
]);
return $oTicket;
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Webservices;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use MetaModel;
use RestUtils;
use Ticket;
use UserRequest;
class RestUtilsTest extends ItopDataTestCase
{
public function testGetFieldListForSingleClass(): void
{
$aList = RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => 'ref,start_date,end_date'], 'output_fields');
$this->assertSame([Ticket::class => ['ref', 'start_date', 'end_date']], $aList);
}
public function testGetFieldListForSingleClassWithInvalidFieldNameFails(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('output_fields: invalid attribute code \'something\' for class \'Ticket\'');
$aList = RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => 'ref,something'], 'output_fields');
$this->assertSame([Ticket::class => ['ref', 'start_date', 'end_date']], $aList);
}
public function testGetFieldListWithAsteriskOnParentClass(): void
{
$aList = RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => '*'], 'output_fields');
$this->assertArrayHasKey(Ticket::class, $aList);
$this->assertContains('operational_status', $aList[Ticket::class]);
$this->assertNotContains('status', $aList[Ticket::class], 'Representation of Class Ticket should not contain status, since it is defined by children');
}
public function testGetFieldListWithAsteriskPlusOnParentClass(): void
{
$aList = RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => '*+'], 'output_fields');
$this->assertArrayHasKey(Ticket::class, $aList);
$this->assertArrayHasKey(UserRequest::class, $aList);
$this->assertContains('operational_status', $aList[Ticket::class]);
$this->assertContains('status', $aList[UserRequest::class]);
}
public function testGetFieldListForMultipleClasses(): void
{
$aList = RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => 'Ticket:ref,start_date,end_date;UserRequest:ref,status'], 'output_fields');
$this->assertArrayHasKey(Ticket::class, $aList);
$this->assertArrayHasKey(UserRequest::class, $aList);
$this->assertContains('ref', $aList[Ticket::class]);
$this->assertContains('end_date', $aList[Ticket::class]);
$this->assertNotContains('status', $aList[Ticket::class]);
$this->assertContains('status', $aList[UserRequest::class]);
$this->assertNotContains('end_date', $aList[UserRequest::class]);
}
public function testGetFieldListForMultipleClassesWithInvalidFieldNameFails(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('output_fields: invalid attribute code \'something\'');
RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => 'Ticket:ref;UserRequest:ref,something'], 'output_fields');
}
public function testGetFieldListForMultipleClassesWithInvalidFieldName(): void
{
$aList = RestUtils::GetFieldList(Ticket::class, (object) ['output_fields' => 'ref, something'], 'output_fields', false);
$this->assertContains('ref', $aList[Ticket::class]);
$this->assertNotContains('something', $aList[Ticket::class]);
}
/**
* @dataProvider extendedOutputDataProvider
*/
public function testIsExtendedOutputRequest(bool $bExpected, string $sFields): void
{
$this->assertSame($bExpected, RestUtils::HasRequestedExtendedOutput($sFields));
}
/**
* @dataProvider allFieldsOutputDataProvider
*/
public function testIsAllFieldsOutputRequest(bool $bExpected, string $sFields): void
{
$this->assertSame($bExpected, RestUtils::HasRequestedAllOutputFields($sFields));
}
public function extendedOutputDataProvider(): array
{
return [
[false, 'ref,start_date,end_date'],
[false, '*'],
[true, '*+'],
[false, 'Ticket:ref'],
[true, 'Ticket:ref;UserRequest:ref'],
];
}
public function allFieldsOutputDataProvider(): array
{
return [
[false, 'ref,start_date,end_date'],
[true, '*'],
[true, '*+'],
[false, 'Ticket:ref'],
[false, 'Ticket:ref;UserRequest:ref'],
];
}
}