diff --git a/application/applicationextension.inc.php b/application/applicationextension.inc.php index d1aa296e44..b40bf1e945 100644 --- a/application/applicationextension.inc.php +++ b/application/applicationextension.inc.php @@ -1929,41 +1929,76 @@ public static function GetClass($oData, $sParamName) * @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; } /** diff --git a/core/restservices.class.inc.php b/core/restservices.class.inc.php index b66ed5e090..ba93092744 100644 --- a/core/restservices.class.inc.php +++ b/core/restservices.class.inc.php @@ -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 @@ public function AddObject($iCode, $sMessage, $oObject, $aFieldSpec = null, $bExt $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 @@ public function ExecOperation($sVersion, $sVerb, $aParams) 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 @@ public function ExecOperation($sVersion, $sVerb, $aParams) $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); - } - - while ($oObject = $oObjectSet->Fetch()) - { - $oResult->AddObject(0, '', $oObject, $aShowFields, $bExtendedOutput); + $oResult = new RestResultWithObjectSets(); + $aCache = []; + $aShowFields = []; + foreach ($oObjectSet->GetSelectedClasses() as $sSelectedClass) { + $aShowFields = array_merge( $aShowFields, RestUtils::GetFieldList($sSelectedClass, $aParams, 'output_fields', false)); + } + + 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(); } diff --git a/tests/php-unit-tests/unitary-tests/webservices/RestTest.php b/tests/php-unit-tests/unitary-tests/webservices/RestTest.php index 6b1c6848a3..f96545ab97 100644 --- a/tests/php-unit-tests/unitary-tests/webservices/RestTest.php +++ b/tests/php-unit-tests/unitary-tests/webservices/RestTest.php @@ -142,6 +142,158 @@ public function testCoreApiGet(){ $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(<<$description

", + "id": "$iIdUserRequest" + }, + "key": "$iIdUserRequest", + "message": "" + }, + "Change::$iIdChange": { + "class": "Change", + "code": 0, + "fields": { + "description": "

$description

", + "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(<<$description

", + "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(<<assertJsonStringEqualsJsonString($sExpectedJsonOuput, $sJSONOutput); + } public function testCoreApiCreate() { // Create ticket @@ -253,12 +405,13 @@ public function testCoreApiDelete() // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - 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; } diff --git a/tests/php-unit-tests/unitary-tests/webservices/RestUtilsTest.php b/tests/php-unit-tests/unitary-tests/webservices/RestUtilsTest.php new file mode 100644 index 0000000000..779c93819f --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/RestUtilsTest.php @@ -0,0 +1,110 @@ + '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'], + ]; + } +} \ No newline at end of file